Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,60 @@ err := client.Users.Delete(ctx, rownd.DeleteUserRequest{
})
```

### User Attributes

User attributes are key-value pairs where values are arrays of strings. They can be used to store additional metadata like app variants, subscription status, or custom application data.

```go
// Create user with attributes
user, err := client.Users.CreateOrUpdate(ctx, rownd.CreateOrUpdateUserRequest{
UserID: "__UUID__",
Data: map[string]interface{}{
"email": "user@example.com",
"first_name": "John",
},
Attributes: map[string][]string{
"rownd:app_variants": {"variant_1", "variant_2"},
"myapp:subscription_status": {"active"},
"myapp:loyalty_points": {"100"},
},
})

// Get user attributes
attrs := user.GetAttributes()
// attrs = {
// "rownd:app_variants": ["variant_1", "variant_2"],
// "myapp:subscription_status": ["active"],
// "myapp:loyalty_points": ["100"]
// }

// Add or update attributes (using PATCH)
user, err = client.Users.AddAttributes(ctx, rownd.AddAttributesRequest{
UserID: "user_id",
Attributes: map[string][]string{
"myapp:loyalty_points": {"200"}, // Updates existing
"myapp:tier": {"gold"}, // Adds new
},
})

// Delete attributes (requires GET + PUT)
user, err = client.Users.DeleteAttributes(ctx, rownd.DeleteAttributesRequest{
UserID: "user_id",
AttributeKeys: []string{"myapp:subscription_status", "myapp:tier"},
})

// You can also manage attributes directly via Patch/CreateOrUpdate
user, err = client.Users.Patch(ctx, rownd.PatchUserRequest{
UserID: "user_id",
Data: map[string]any{
"email": "newemail@example.com",
},
Attributes: map[string][]string{
"myapp:new_attribute": {"value1", "value2"},
},
})
```

## Group Management

### Groups
Expand Down
145 changes: 145 additions & 0 deletions pkg/rownd/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type User struct {
Groups []UserGroupMembership `json:"groups"`
Meta UserMeta `json:"meta"`
ConnectionMap map[string]UserConnection `json:"connection_map"`
Attributes map[string][]string `json:"attributes"`
}

// UserConnection ...
Expand Down Expand Up @@ -223,6 +224,9 @@ type CreateOrUpdateUserRequest struct {

// ConnectionMap is optional
ConnectionMap []interface{} `json:"connection_map,omitempty"`

// Attributes are optional key-value pairs where values are arrays of strings
Attributes map[string][]string `json:"attributes,omitempty"`
}

func (r *CreateOrUpdateUserRequest) params() url.Values {
Expand Down Expand Up @@ -289,6 +293,9 @@ type PatchUserRequest struct {
WriteDataToIntegrations *bool `json:"-"`

Data map[string]any `json:"data"`

// Attributes are optional key-value pairs where values are arrays of strings
Attributes map[string][]string `json:"attributes,omitempty"`
}

func (r *PatchUserRequest) params() url.Values {
Expand Down Expand Up @@ -386,3 +393,141 @@ func (u *User) GetID() string {
}
return ""
}

// GetAttributes returns the user's attributes
func (u *User) GetAttributes() map[string][]string {
if u.Attributes == nil {
return make(map[string][]string)
}
return u.Attributes
}

// AddAttributesRequest ...
type AddAttributesRequest struct {
// UserID is the user id.
UserID string `json:"-"`

// Attributes to add
Attributes map[string][]string `json:"-"`

// WriteDataToIntegrations is a query parameter that dictates if Rownd should write the
// profile data changes to integrations attached to your application.
// default: true
WriteDataToIntegrations *bool `json:"-"`
}

func (r *AddAttributesRequest) validate() error {
var errs []error

if r.UserID == "" {
errs = append(errs, NewError(ErrValidation, "user id is required", nil))
}

if r.Attributes == nil || len(r.Attributes) == 0 {
errs = append(errs, NewError(ErrValidation, "attributes are required", nil))
}

if len(errs) == 0 {
return nil
}

return &MultiError{errors: errs}
}

// AddAttributes adds attributes to a user using PATCH
func (c *userClient) AddAttributes(ctx context.Context, request AddAttributesRequest) (*User, error) {
if err := request.validate(); err != nil {
return nil, err
}

patchReq := PatchUserRequest{
UserID: request.UserID,
WriteDataToIntegrations: request.WriteDataToIntegrations,
Data: map[string]any{}, // Empty data since we're only updating attributes
Attributes: request.Attributes,
}

return c.Patch(ctx, patchReq)
}

// DeleteAttributesRequest ...
type DeleteAttributesRequest struct {
// UserID is the user id.
UserID string `json:"-"`

// AttributeKeys to delete
AttributeKeys []string `json:"-"`

// WriteDataToIntegrations is a query parameter that dictates if Rownd should write the
// profile data changes to integrations attached to your application.
// default: true
WriteDataToIntegrations *bool `json:"-"`
}

func (r *DeleteAttributesRequest) validate() error {
var errs []error

if r.UserID == "" {
errs = append(errs, NewError(ErrValidation, "user id is required", nil))
}

if r.AttributeKeys == nil || len(r.AttributeKeys) == 0 {
errs = append(errs, NewError(ErrValidation, "attribute keys are required", nil))
}

if len(errs) == 0 {
return nil
}

return &MultiError{errors: errs}
}

// DeleteAttributes deletes attributes from a user by fetching the full user and PUTing without those attributes
func (c *userClient) DeleteAttributes(ctx context.Context, request DeleteAttributesRequest) (*User, error) {
if err := request.validate(); err != nil {
return nil, err
}

// First, get the current user
currentUser, err := c.Get(ctx, GetUserRequest{UserID: request.UserID})
if err != nil {
return nil, err
}

// Create a copy of attributes without the ones to delete
newAttributes := make(map[string][]string)
if currentUser.Attributes != nil {
for key, value := range currentUser.Attributes {
// Check if this key should be deleted
shouldDelete := false
for _, deleteKey := range request.AttributeKeys {
if key == deleteKey {
shouldDelete = true
break
}
}
if !shouldDelete {
newAttributes[key] = value
}
}
}

// Use CreateOrUpdate (PUT) to update the user with the new attributes
updateReq := CreateOrUpdateUserRequest{
UserID: request.UserID,
WriteDataToIntegrations: request.WriteDataToIntegrations,
Data: currentUser.Data,
Attributes: newAttributes,
}

// Include ConnectionMap if it exists
if currentUser.ConnectionMap != nil && len(currentUser.ConnectionMap) > 0 {
// Convert ConnectionMap to []interface{} format expected by the API
connectionMap := make([]interface{}, 0)
// Note: The actual conversion logic would depend on API requirements
// For now, we'll leave it empty as ConnectionMap handling might need specific logic
updateReq.ConnectionMap = connectionMap
}

return c.CreateOrUpdate(ctx, updateReq)
}
114 changes: 114 additions & 0 deletions pkg/rownd/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,118 @@ func TestRowndUserOperations(t *testing.T) {
})
assert.NoError(t, err)
})

t.Run("user attributes operations", func(t *testing.T) {
// Create a user for attribute testing
userData := map[string]interface{}{
"email": fmt.Sprintf("test.attributes.%d@example.com", time.Now().UnixNano()),
"first_name": "Attribute",
"last_name": "Test",
}

user, err := client.Users.CreateOrUpdate(ctx, rownd.CreateOrUpdateUserRequest{
UserID: "__UUID__",
Data: userData,
Attributes: map[string][]string{
"rownd:app_variants": {"variant_1", "variant_2"},
},
})
assert.NoError(t, err)
assert.NotNil(t, user)
assert.NotEmpty(t, user.ID)
testUserID := user.ID

// Test GetAttributes method
attrs := user.GetAttributes()
assert.NotNil(t, attrs)
assert.Equal(t, []string{"variant_1", "variant_2"}, attrs["rownd:app_variants"])

// Test AddAttributes
t.Run("add attributes", func(t *testing.T) {
user, err := client.Users.AddAttributes(ctx, rownd.AddAttributesRequest{
UserID: testUserID,
Attributes: map[string][]string{
"myapp:subscription_status": {"active"},
"myapp:loyalty_points": {"100"},
},
})
assert.NoError(t, err)
assert.NotNil(t, user)

// Verify attributes were added
attrs := user.GetAttributes()
assert.Equal(t, []string{"variant_1", "variant_2"}, attrs["rownd:app_variants"])
assert.Equal(t, []string{"active"}, attrs["myapp:subscription_status"])
assert.Equal(t, []string{"100"}, attrs["myapp:loyalty_points"])
})

// Test updating existing attributes
t.Run("update existing attributes", func(t *testing.T) {
user, err := client.Users.AddAttributes(ctx, rownd.AddAttributesRequest{
UserID: testUserID,
Attributes: map[string][]string{
"myapp:loyalty_points": {"200"}, // Update existing
"myapp:tier": {"gold"}, // Add new
},
})
assert.NoError(t, err)
assert.NotNil(t, user)

attrs := user.GetAttributes()
assert.Equal(t, []string{"200"}, attrs["myapp:loyalty_points"])
assert.Equal(t, []string{"gold"}, attrs["myapp:tier"])
})

// Test DeleteAttributes
t.Run("delete attributes", func(t *testing.T) {
user, err := client.Users.DeleteAttributes(ctx, rownd.DeleteAttributesRequest{
UserID: testUserID,
AttributeKeys: []string{"myapp:subscription_status", "myapp:tier"},
})
assert.NoError(t, err)
assert.NotNil(t, user)

// Verify attributes were deleted
attrs := user.GetAttributes()
assert.Equal(t, []string{"variant_1", "variant_2"}, attrs["rownd:app_variants"])
assert.Equal(t, []string{"200"}, attrs["myapp:loyalty_points"])
_, hasSubscription := attrs["myapp:subscription_status"]
assert.False(t, hasSubscription)
_, hasTier := attrs["myapp:tier"]
assert.False(t, hasTier)
})

// Test validation errors
t.Run("validation errors", func(t *testing.T) {
// Test AddAttributes without UserID
_, err := client.Users.AddAttributes(ctx, rownd.AddAttributesRequest{
Attributes: map[string][]string{"test": {"value"}},
})
assert.Error(t, err)

// Test AddAttributes without attributes
_, err = client.Users.AddAttributes(ctx, rownd.AddAttributesRequest{
UserID: testUserID,
})
assert.Error(t, err)

// Test DeleteAttributes without UserID
_, err = client.Users.DeleteAttributes(ctx, rownd.DeleteAttributesRequest{
AttributeKeys: []string{"test"},
})
assert.Error(t, err)

// Test DeleteAttributes without keys
_, err = client.Users.DeleteAttributes(ctx, rownd.DeleteAttributesRequest{
UserID: testUserID,
})
assert.Error(t, err)
})

// Cleanup
err = client.Users.Delete(ctx, rownd.DeleteUserRequest{
UserID: testUserID,
})
assert.NoError(t, err)
})
}
Loading