Skip to content
Open
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
69 changes: 69 additions & 0 deletions pkg/actions/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"

Expand All @@ -14,6 +15,8 @@ import (
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"github.com/segmentio/ksuid"
"go.uber.org/zap"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
Expand Down Expand Up @@ -633,3 +636,69 @@ func isNullValue(v *structpb.Value) bool {
_, isNull := v.GetKind().(*structpb.Value_NullValue)
return isNull
}

// toDisplayName converts a snake_case field name to a Title Case display name.
func toDisplayName(name string) string {
// Replace underscores with spaces, then apply title case
spaced := strings.ReplaceAll(name, "_", " ")
return cases.Title(language.English).String(spaced)
}

// NewUpdateProfileSchema creates a BatonActionSchema for updating user profiles.
// It follows the convention for ACTION_TYPE_ACCOUNT_UPDATE_PROFILE actions:
// - Named "update_profile"
// - Includes a "user_id" ResourceIdField
// - Includes StringField for each known attribute name
// - Optionally includes "custom_attributes" StringMapField if allowsCustom is true
func NewUpdateProfileSchema(allowsCustom bool, attributeNames []string) *v2.BatonActionSchema {
arguments := []*config.Field{
config.Field_builder{
Name: "user_id",
DisplayName: "User ID",
Description: "The user to update",
IsRequired: true,
ResourceIdField: &config.ResourceIdField{
Rules: &config.ResourceIDRules{
AllowedResourceTypeIds: []string{"user"},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have some connectors with resource type IDs other than "user" that have the user trait. If this action is registered on a specific resource type, wouldn't the input here always be a resource of that type?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not necessarily, this is just a user_id field on the schema, not the resource type on the schema itself. These can be anything

},
},
}.Build(),
}

// Dedupe attributeNames and remove reserved names
seen := make(map[string]bool)
reservedNames := map[string]bool{"user_id": true, "custom_attributes": true}
filteredNames := make([]string, 0, len(attributeNames))
for _, name := range attributeNames {
if reservedNames[name] || seen[name] {
continue
}
seen[name] = true
filteredNames = append(filteredNames, name)
}

for _, name := range filteredNames {
arguments = append(arguments, config.Field_builder{
Name: name,
DisplayName: toDisplayName(name),
StringField: &config.StringField{},
}.Build())
}

if allowsCustom {
arguments = append(arguments, config.Field_builder{
Name: "custom_attributes",
DisplayName: "Custom Attributes",
Description: "Additional custom attributes to set on the user",
StringMapField: &config.StringMapField{},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are custom attributes supposed to be handled? eg: If I make an action and use NewUpdateProfileSchema(true, ["employee_id"]) for the schema, then invoke the update_profile action with args...

{
  "employee_id": "1234",
  "custom_attributes": {
    "employee_id": "5678"
  }
}

...what is supposed to happen? It's fine if the answer is, "Don't do that."

Also how does this schema handle updating a subset of attributes? If it only adds/updates attributes that are provided (and doesn't delete the ones not present), then I don't think there's a way to delete an attribute. They can only be set to empty string.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ggreer as for what is supposed to happen, that's sorta connector dependent. For example, in the demo connector, if you provided an email and a custom_attributes.email, we would set the email of the user to the former, and we have a profile map object where we would include the latter. There may be other connectors where a configuration like that is nonsensical and yeah, the answer would be "Don't do that". Although, now that I'm thinking about it more, C1 might not even let you configure that because of the name conflict. In which case, you could only do that if you invoke the action directly.

As of now, my intention was that connectors should only update the set of attributes they learn about, which does mean you can only empty-string those values. That felt like the safest path, but we still have the opportunity to revisit that. It's also not enforced in any way, just guidance. C1 sends every field for the user it can calculate a value for, even if it's the empty string (iirc), so again, a connector could decide to treat missing values as "delete-this-field" (whatever that means for that particular connector)

}.Build())
}

return v2.BatonActionSchema_builder{
Name: "update_profile",
DisplayName: "Update Profile",
Description: "Update a user's profile attributes",
ActionType: []v2.ActionType{v2.ActionType_ACTION_TYPE_ACCOUNT_UPDATE_PROFILE},
Arguments: arguments,
}.Build()
}
92 changes: 92 additions & 0 deletions pkg/actions/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -546,3 +546,95 @@ func TestActionHandlerGoroutineLeaks(t *testing.T) {
require.LessOrEqual(t, finalCount, initialCount+1, "goroutine leak detected after context cancellation")
})
}

func TestNewUpdateProfileSchema(t *testing.T) {
t.Run("basic schema without custom attributes", func(t *testing.T) {
schema := NewUpdateProfileSchema(false, nil)

require.Equal(t, "update_profile", schema.GetName())
require.Equal(t, "Update Profile", schema.GetDisplayName())
require.Equal(t, "Update a user's profile attributes", schema.GetDescription())
require.Contains(t, schema.GetActionType(), v2.ActionType_ACTION_TYPE_ACCOUNT_UPDATE_PROFILE)

// Should have only user_id field
require.Len(t, schema.GetArguments(), 1)

userIdField := schema.GetArguments()[0]
require.Equal(t, "user_id", userIdField.GetName())
require.Equal(t, "User ID", userIdField.GetDisplayName())
require.True(t, userIdField.GetIsRequired())
require.NotNil(t, userIdField.GetResourceIdField())
require.Equal(t, []string{"user"}, userIdField.GetResourceIdField().GetRules().GetAllowedResourceTypeIds())
})

t.Run("schema with attribute names", func(t *testing.T) {
schema := NewUpdateProfileSchema(false, []string{"first_name", "last_name", "email"})

// Should have user_id + 3 attribute fields
require.Len(t, schema.GetArguments(), 4)

// Verify user_id is first
require.Equal(t, "user_id", schema.GetArguments()[0].GetName())

// Verify attribute fields
firstNameField := schema.GetArguments()[1]
require.Equal(t, "first_name", firstNameField.GetName())
require.Equal(t, "First Name", firstNameField.GetDisplayName())
require.NotNil(t, firstNameField.GetStringField())

lastNameField := schema.GetArguments()[2]
require.Equal(t, "last_name", lastNameField.GetName())
require.Equal(t, "Last Name", lastNameField.GetDisplayName())
require.NotNil(t, lastNameField.GetStringField())

emailField := schema.GetArguments()[3]
require.Equal(t, "email", emailField.GetName())
require.Equal(t, "Email", emailField.GetDisplayName())
require.NotNil(t, emailField.GetStringField())
})

t.Run("schema with custom attributes enabled", func(t *testing.T) {
schema := NewUpdateProfileSchema(true, []string{"first_name"})

// Should have user_id + first_name + custom_attributes
require.Len(t, schema.GetArguments(), 3)

customAttrsField := schema.GetArguments()[2]
require.Equal(t, "custom_attributes", customAttrsField.GetName())
require.Equal(t, "Custom Attributes", customAttrsField.GetDisplayName())
require.NotNil(t, customAttrsField.GetStringMapField())
})

t.Run("schema without custom attributes disabled", func(t *testing.T) {
schema := NewUpdateProfileSchema(false, []string{"first_name"})

// Should have user_id + first_name only
require.Len(t, schema.GetArguments(), 2)

// Verify no custom_attributes field
for _, field := range schema.GetArguments() {
require.NotEqual(t, "custom_attributes", field.GetName())
}
})
}

func TestToDisplayName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"first_name", "First Name"},
{"last_name", "Last Name"},
{"email", "Email"},
{"user_profile_picture_url", "User Profile Picture Url"},
{"id", "Id"},
{"", ""},
}

for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result := toDisplayName(tc.input)
require.Equal(t, tc.expected, result)
})
}
}
Loading