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 }