attio-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +164 -0
  4. data/.simplecov +17 -0
  5. data/.yardopts +9 -0
  6. data/CHANGELOG.md +27 -0
  7. data/CONTRIBUTING.md +333 -0
  8. data/INTEGRATION_TEST_STATUS.md +149 -0
  9. data/LICENSE +21 -0
  10. data/README.md +638 -0
  11. data/Rakefile +8 -0
  12. data/attio-ruby.gemspec +61 -0
  13. data/docs/CODECOV_SETUP.md +34 -0
  14. data/examples/basic_usage.rb +149 -0
  15. data/examples/oauth_flow.rb +843 -0
  16. data/examples/oauth_flow_README.md +84 -0
  17. data/examples/typed_records_example.rb +167 -0
  18. data/examples/webhook_server.rb +463 -0
  19. data/lib/attio/api_resource.rb +539 -0
  20. data/lib/attio/builders/name_builder.rb +181 -0
  21. data/lib/attio/client.rb +160 -0
  22. data/lib/attio/errors.rb +126 -0
  23. data/lib/attio/internal/record.rb +359 -0
  24. data/lib/attio/oauth/client.rb +219 -0
  25. data/lib/attio/oauth/scope_validator.rb +162 -0
  26. data/lib/attio/oauth/token.rb +158 -0
  27. data/lib/attio/resources/attribute.rb +332 -0
  28. data/lib/attio/resources/comment.rb +114 -0
  29. data/lib/attio/resources/company.rb +224 -0
  30. data/lib/attio/resources/entry.rb +208 -0
  31. data/lib/attio/resources/list.rb +196 -0
  32. data/lib/attio/resources/meta.rb +113 -0
  33. data/lib/attio/resources/note.rb +213 -0
  34. data/lib/attio/resources/object.rb +66 -0
  35. data/lib/attio/resources/person.rb +294 -0
  36. data/lib/attio/resources/task.rb +147 -0
  37. data/lib/attio/resources/thread.rb +99 -0
  38. data/lib/attio/resources/typed_record.rb +98 -0
  39. data/lib/attio/resources/webhook.rb +224 -0
  40. data/lib/attio/resources/workspace_member.rb +136 -0
  41. data/lib/attio/util/configuration.rb +166 -0
  42. data/lib/attio/util/id_extractor.rb +115 -0
  43. data/lib/attio/util/webhook_signature.rb +175 -0
  44. data/lib/attio/version.rb +6 -0
  45. data/lib/attio/webhook/event.rb +114 -0
  46. data/lib/attio/webhook/signature_verifier.rb +73 -0
  47. data/lib/attio.rb +123 -0
  48. metadata +402 -0
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+
5
+ module Attio
6
+ # Represents a task in Attio
7
+ class Task < APIResource
8
+ # Don't use api_operations for list since we need custom handling
9
+ api_operations :create, :retrieve, :update, :delete
10
+
11
+ # API endpoint path for tasks
12
+ # @return [String] The API path
13
+ def self.resource_path
14
+ "tasks"
15
+ end
16
+
17
+ # Custom list implementation to handle query params properly
18
+ def self.list(**params)
19
+ # Query params should be part of the request, not opts
20
+ query_params = params.slice(:limit, :offset, :sort, :linked_object, :linked_record_id, :assignee, :is_completed)
21
+ opts = params.except(:limit, :offset, :sort, :linked_object, :linked_record_id, :assignee, :is_completed)
22
+
23
+ response = execute_request(:GET, resource_path, query_params, opts)
24
+ ListObject.new(response, self, params, opts)
25
+ end
26
+
27
+ class << self
28
+ alias_method :all, :list
29
+ end
30
+
31
+ # Override create to handle required content parameter
32
+ def self.create(content: nil, format: "plaintext", **params)
33
+ raise ArgumentError, "Content is required" if content.nil? || content.to_s.empty?
34
+
35
+ request_params = {
36
+ data: {
37
+ content: content, # API expects 'content'
38
+ format: format, # Format is required
39
+ is_completed: params[:is_completed] || false,
40
+ linked_records: params[:linked_records] || [],
41
+ assignees: params[:assignees] || []
42
+ }
43
+ }
44
+
45
+ # deadline_at must be present (null or valid date)
46
+ request_params[:data][:deadline_at] = params[:deadline_at]
47
+
48
+ # Remove the params that we've already included in request_params
49
+ opts = params.except(:content, :format, :deadline_at, :is_completed, :linked_records, :assignees)
50
+
51
+ response = execute_request(:POST, resource_path, request_params, opts)
52
+ new(response["data"] || response, opts)
53
+ end
54
+
55
+ # Override update to use PATCH with data wrapper
56
+ def self.update(id, **params)
57
+ validate_id!(id)
58
+
59
+ request_params = {
60
+ data: params.slice(:content, :format, :deadline_at, :is_completed, :linked_records, :assignees).compact
61
+ }
62
+
63
+ # Remove the params that we've already included in request_params
64
+ opts = params.except(:content, :format, :deadline_at, :is_completed, :linked_records, :assignees)
65
+
66
+ response = execute_request(:PATCH, "#{resource_path}/#{id}", request_params, opts)
67
+ new(response["data"] || response, opts)
68
+ end
69
+
70
+ # Define attribute accessors
71
+ attr_attio :content_plaintext, :is_completed, :linked_records, :assignees, :created_by_actor
72
+
73
+ # Parse deadline_at as Time
74
+ def deadline_at
75
+ value = @attributes[:deadline_at]
76
+ return nil if value.nil?
77
+
78
+ case value
79
+ when Time
80
+ value
81
+ when String
82
+ Time.parse(value)
83
+ else
84
+ value
85
+ end
86
+ end
87
+
88
+ # Convenience method to mark task as completed
89
+ def complete!(**opts)
90
+ raise InvalidRequestError, "Cannot complete a task without an ID" unless persisted?
91
+
92
+ params = {
93
+ data: {
94
+ is_completed: true
95
+ }
96
+ }
97
+
98
+ response = self.class.send(:execute_request, :PATCH, resource_path, params, opts)
99
+ update_from(response["data"] || response)
100
+ self
101
+ end
102
+
103
+ # Override save to handle task-specific attributes
104
+ def save(**opts)
105
+ raise InvalidRequestError, "Cannot save a task without an ID" unless persisted?
106
+
107
+ params = {
108
+ data: changed_attributes.slice(:content, :deadline_at, :is_completed, :linked_records, :assignees).compact
109
+ }
110
+
111
+ return self unless params[:data].any?
112
+
113
+ response = self.class.send(:execute_request, :PATCH, resource_path, params, opts)
114
+ update_from(response["data"] || response)
115
+ reset_changes!
116
+ self
117
+ end
118
+
119
+ # Override destroy to use the correct task ID
120
+ def destroy(**opts)
121
+ raise InvalidRequestError, "Cannot destroy a task without an ID" unless persisted?
122
+
123
+ task_id = extract_task_id
124
+ self.class.send(:execute_request, :DELETE, "#{self.class.resource_path}/#{task_id}", {}, opts)
125
+ @attributes.clear
126
+ @changed_attributes.clear
127
+ @id = nil
128
+ true
129
+ end
130
+
131
+ private
132
+
133
+ def extract_task_id
134
+ case id
135
+ when Hash
136
+ id[:task_id] || id["task_id"]
137
+ else
138
+ id
139
+ end
140
+ end
141
+
142
+ def resource_path
143
+ task_id = extract_task_id
144
+ "#{self.class.resource_path}/#{task_id}"
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+
5
+ module Attio
6
+ # Represents a comment thread in Attio (read-only)
7
+ class Thread < APIResource
8
+ # Threads only support list and retrieve (read-only resource)
9
+ # Don't use api_operations for list since we need custom handling
10
+ api_operations :retrieve
11
+
12
+ # API endpoint path for threads
13
+ # @return [String] The API path
14
+ def self.resource_path
15
+ "threads"
16
+ end
17
+
18
+ # Custom list implementation to handle query params properly
19
+ def self.list(**params)
20
+ # Query params should be part of the request, not opts
21
+ query_params = params.slice(:record_id, :object, :entry_id, :list, :limit, :offset)
22
+ opts = params.except(:record_id, :object, :entry_id, :list, :limit, :offset)
23
+
24
+ response = execute_request(:GET, resource_path, query_params, opts)
25
+ ListObject.new(response, self, params, opts)
26
+ end
27
+
28
+ class << self
29
+ alias_method :all, :list
30
+ end
31
+
32
+ # Define attribute accessors
33
+ attr_attio :comments
34
+
35
+ # Helper methods for working with comments
36
+ def comment_count
37
+ comments&.length || 0
38
+ end
39
+
40
+ def has_comments?
41
+ comment_count > 0
42
+ end
43
+
44
+ # Get the first comment in the thread
45
+ # @return [Hash, nil] First comment or nil if empty
46
+ def first_comment
47
+ comments&.first
48
+ end
49
+
50
+ # Get the last comment in the thread
51
+ # @return [Hash, nil] Last comment or nil if empty
52
+ def last_comment
53
+ comments&.last
54
+ end
55
+
56
+ # Threads are read-only
57
+ def immutable?
58
+ true
59
+ end
60
+
61
+ # Override save to raise error since threads are read-only
62
+ def save(**opts)
63
+ raise InvalidRequestError, "Threads are read-only and cannot be modified"
64
+ end
65
+
66
+ # Override destroy to raise error since threads are read-only
67
+ def destroy(**opts)
68
+ raise InvalidRequestError, "Threads are read-only and cannot be deleted"
69
+ end
70
+
71
+ private
72
+
73
+ def extract_thread_id
74
+ case id
75
+ when Hash
76
+ id[:thread_id] || id["thread_id"]
77
+ else
78
+ id
79
+ end
80
+ end
81
+
82
+ def resource_path
83
+ thread_id = extract_thread_id
84
+ "#{self.class.resource_path}/#{thread_id}"
85
+ end
86
+
87
+ def to_h
88
+ {
89
+ id: id,
90
+ comments: comments,
91
+ created_at: created_at&.iso8601
92
+ }.compact
93
+ end
94
+
95
+ def inspect
96
+ "#<#{self.class.name}:#{object_id} id=#{id.inspect} comments=#{comment_count}>"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../internal/record"
4
+
5
+ module Attio
6
+ # Base class for type-specific record classes (e.g., Person, Company)
7
+ # Provides a more object-oriented interface for working with specific Attio objects
8
+ class TypedRecord < Internal::Record
9
+ class << self
10
+ # Define the object type for this class
11
+ # @param type [String] The Attio object type (e.g., "people", "companies")
12
+ def object_type(type = nil)
13
+ if type
14
+ @object_type = type
15
+ else
16
+ @object_type || raise(NotImplementedError, "#{self} must define object_type")
17
+ end
18
+ end
19
+
20
+ # Override list to automatically include object type
21
+ def list(**opts)
22
+ super(object: object_type, **opts)
23
+ end
24
+
25
+ # Override retrieve to automatically include object type
26
+ def retrieve(record_id, **opts)
27
+ super(object: object_type, record_id: record_id, **opts)
28
+ end
29
+
30
+ # Override create to automatically include object type
31
+ def create(values: {}, **opts)
32
+ super(object: object_type, values: values, **opts)
33
+ end
34
+
35
+ # Override update to automatically include object type
36
+ def update(record_id, values: {}, **opts)
37
+ super(object: object_type, record_id: record_id, data: {values: values}, **opts)
38
+ end
39
+
40
+ # Override delete to automatically include object type
41
+ def delete(record_id, **opts)
42
+ # The parent delete expects object in opts for records
43
+ simple_id = record_id.is_a?(Hash) ? record_id["record_id"] : record_id
44
+ execute_request(:DELETE, "objects/#{object_type}/records/#{simple_id}", {}, opts)
45
+ true
46
+ end
47
+
48
+ # Provide a more intuitive find method
49
+ def find(record_id, **opts)
50
+ retrieve(record_id, **opts)
51
+ end
52
+
53
+ # Provide a more intuitive all method
54
+ def all(**opts)
55
+ list(**opts)
56
+ end
57
+
58
+ # Search with a query string
59
+ def search(query, **opts)
60
+ list(**opts.merge(params: {q: query}))
61
+ end
62
+
63
+ # Find by a specific attribute value
64
+ def find_by(attribute, value, **opts)
65
+ list(**opts.merge(params: {
66
+ filter: {
67
+ attribute => value
68
+ }
69
+ })).first
70
+ end
71
+ end
72
+
73
+ # Override initialize to ensure object type is set
74
+ def initialize(attributes = {}, opts = {})
75
+ super
76
+ # Ensure the object type matches the class
77
+ if respond_to?(:object) && object != self.class.object_type
78
+ raise ArgumentError, "Object type mismatch: expected #{self.class.object_type}, got #{object}"
79
+ end
80
+ end
81
+
82
+ # Override save to include object type
83
+ def save(**opts)
84
+ raise InvalidRequestError, "Cannot save without an ID" unless persisted?
85
+ return self unless changed?
86
+
87
+ self.class.update(id, values: changed_attributes, **opts)
88
+ end
89
+
90
+ # Override destroy to include object type
91
+ def destroy(**opts)
92
+ raise InvalidRequestError, "Cannot destroy without an ID" unless persisted?
93
+
94
+ # Just call the parent destroy method which handles everything correctly
95
+ super
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+ require_relative "../webhook/signature_verifier"
5
+ require_relative "../webhook/event"
6
+
7
+ module Attio
8
+ # Represents a webhook configuration in Attio
9
+ class Webhook < APIResource
10
+ api_operations :list, :retrieve, :create, :update, :delete
11
+
12
+ # API endpoint path for webhooks
13
+ # @return [String] The API path
14
+ def self.resource_path
15
+ "webhooks"
16
+ end
17
+
18
+ # Event types
19
+ EVENTS = %w[
20
+ record.created
21
+ record.updated
22
+ record.deleted
23
+ list_entry.created
24
+ list_entry.deleted
25
+ note.created
26
+ note.deleted
27
+ task.created
28
+ task.updated
29
+ task.deleted
30
+ object.created
31
+ object.updated
32
+ attribute.created
33
+ attribute.updated
34
+ attribute.archived
35
+ ].freeze
36
+
37
+ # Define known attributes with proper accessors
38
+ attr_attio :target_url, :subscriptions, :status
39
+
40
+ # Read-only attributes
41
+ attr_reader :secret, :last_event_at, :created_by_actor
42
+ attr_accessor :active
43
+
44
+ # Alias url to target_url for convenience
45
+ alias_method :url, :target_url
46
+ alias_method :url=, :target_url=
47
+
48
+ def initialize(attributes = {}, opts = {})
49
+ super
50
+ normalized_attrs = normalize_attributes(attributes)
51
+ @secret = normalized_attrs[:secret]
52
+ @last_event_at = parse_timestamp(normalized_attrs[:last_event_at])
53
+ @created_by_actor = normalized_attrs[:created_by_actor]
54
+
55
+ # Map status to active for convenience
56
+ if status == "active"
57
+ instance_variable_set(:@active, true)
58
+ elsif status == "paused"
59
+ instance_variable_set(:@active, false)
60
+ end
61
+ end
62
+
63
+ def resource_path
64
+ raise InvalidRequestError, "Cannot generate path without an ID" unless persisted?
65
+ webhook_id = Util::IdExtractor.extract_for_resource(id, :webhook)
66
+ "#{self.class.resource_path}/#{webhook_id}"
67
+ end
68
+
69
+ # Override save to handle nested ID
70
+ def save(**)
71
+ raise InvalidRequestError, "Cannot save a webhook without an ID" unless persisted?
72
+ return self unless changed?
73
+
74
+ webhook_id = Util::IdExtractor.extract_for_resource(id, :webhook)
75
+ self.class.update(webhook_id, changed_attributes, **)
76
+ end
77
+
78
+ # Override destroy to handle nested ID
79
+ def destroy(**opts)
80
+ raise InvalidRequestError, "Cannot destroy a webhook without an ID" unless persisted?
81
+
82
+ webhook_id = Util::IdExtractor.extract_for_resource(id, :webhook)
83
+ self.class.delete(webhook_id, **opts)
84
+ freeze
85
+ true
86
+ end
87
+
88
+ # Check if webhook is active
89
+ def active?
90
+ active == true
91
+ end
92
+
93
+ # Check if webhook is paused
94
+ def paused?
95
+ !active?
96
+ end
97
+
98
+ # Pause the webhook
99
+ def pause(**opts)
100
+ self.active = false
101
+ save(**opts)
102
+ end
103
+
104
+ # Resume the webhook
105
+ def resume(**opts)
106
+ self.active = true
107
+ save(**opts)
108
+ end
109
+ alias_method :activate, :resume
110
+
111
+ # Test the webhook with a sample payload
112
+ def test(**opts)
113
+ raise InvalidRequestError, "Cannot test a webhook without an ID" unless persisted?
114
+
115
+ self.class.send(:execute_request, :POST, "#{resource_path}/test", {}, opts)
116
+ true
117
+ end
118
+
119
+ # Get recent deliveries for this webhook
120
+ def deliveries(params = {}, **opts)
121
+ raise InvalidRequestError, "Cannot get deliveries for a webhook without an ID" unless persisted?
122
+
123
+ response = self.class.send(:execute_request, :GET, "#{resource_path}/deliveries", params, opts)
124
+ response[:data] || []
125
+ end
126
+
127
+ # Convert webhook to hash representation
128
+ # @return [Hash] Webhook data as a hash
129
+ def to_h
130
+ super.merge(
131
+ target_url: target_url,
132
+ subscriptions: subscriptions,
133
+ status: status,
134
+ secret: secret,
135
+ last_event_at: last_event_at&.iso8601,
136
+ created_by_actor: created_by_actor
137
+ ).compact
138
+ end
139
+
140
+ class << self
141
+ # Override create to handle keyword arguments
142
+ def create(**kwargs)
143
+ opts = {}
144
+ opts[:api_key] = kwargs.delete(:api_key) if kwargs.key?(:api_key)
145
+ prepared_params = prepare_params_for_create(kwargs)
146
+ response = execute_request(:POST, resource_path, prepared_params, opts)
147
+ new(response["data"] || response, opts)
148
+ end
149
+
150
+ # Override retrieve to handle hash IDs
151
+ def retrieve(id, **opts)
152
+ webhook_id = Util::IdExtractor.extract_for_resource(id, :webhook)
153
+ response = execute_request(:GET, "#{resource_path}/#{webhook_id}", {}, opts)
154
+ new(response["data"] || response, opts)
155
+ end
156
+
157
+ # Override delete to handle hash IDs
158
+ def delete(id, **opts)
159
+ webhook_id = Util::IdExtractor.extract_for_resource(id, :webhook)
160
+ execute_request(:DELETE, "#{resource_path}/#{webhook_id}", {}, opts)
161
+ true
162
+ end
163
+
164
+ # Override create to handle validation
165
+ def prepare_params_for_create(params)
166
+ # Handle both url and target_url parameters for convenience
167
+ target_url = params[:target_url] || params["target_url"] || params[:url] || params["url"]
168
+ validate_target_url!(target_url)
169
+ subscriptions = params[:subscriptions] || params["subscriptions"]
170
+ validate_subscriptions!(subscriptions)
171
+
172
+ {
173
+ data: {
174
+ target_url: target_url,
175
+ subscriptions: Array(subscriptions).map do |sub|
176
+ # Ensure each subscription has a filter
177
+ sub = sub.is_a?(Hash) ? sub : {"event_type" => sub}
178
+ sub["filter"] ||= {"$and" => []} # Default empty filter
179
+ sub
180
+ end
181
+ }
182
+ }
183
+ end
184
+
185
+ # Override update params preparation
186
+ def prepare_params_for_update(params)
187
+ {
188
+ data: params
189
+ }
190
+ end
191
+
192
+ private
193
+
194
+ def validate_target_url!(url)
195
+ raise BadRequestError, "target_url or url is required" if url.nil? || url.empty?
196
+
197
+ uri = URI.parse(url)
198
+ unless uri.scheme == "https"
199
+ raise BadRequestError, "Webhook target_url must use HTTPS"
200
+ end
201
+ rescue URI::InvalidURIError
202
+ raise BadRequestError, "Invalid webhook target_url"
203
+ end
204
+
205
+ def validate_subscriptions!(subscriptions)
206
+ raise ArgumentError, "subscriptions are required" if subscriptions.nil? || subscriptions.empty?
207
+ raise ArgumentError, "subscriptions must be an array" unless subscriptions.is_a?(Array)
208
+
209
+ subscriptions.each do |sub|
210
+ event_type = if sub.is_a?(Hash)
211
+ sub[:event_type] || sub["event_type"]
212
+ else
213
+ sub # sub is a string representing the event type
214
+ end
215
+ raise ArgumentError, "Each subscription must have an event_type" unless event_type
216
+ end
217
+ end
218
+ end
219
+
220
+ # Constants to match expected API
221
+ SignatureVerifier = WebhookUtils::SignatureVerifier
222
+ Event = WebhookUtils::Event
223
+ end
224
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+
5
+ module Attio
6
+ # Represents a workspace member in Attio (read-only)
7
+ class WorkspaceMember < APIResource
8
+ api_operations :list, :retrieve
9
+
10
+ # API endpoint path for workspace members
11
+ # @return [String] The API path
12
+ def self.resource_path
13
+ "workspace_members"
14
+ end
15
+
16
+ # Read-only attributes - workspace members are immutable via API
17
+ attr_reader :email_address, :first_name, :last_name, :avatar_url,
18
+ :access_level, :status, :invited_at, :last_accessed_at
19
+
20
+ def initialize(attributes = {}, opts = {})
21
+ super
22
+ normalized_attrs = normalize_attributes(attributes)
23
+ @email_address = normalized_attrs[:email_address]
24
+ @first_name = normalized_attrs[:first_name]
25
+ @last_name = normalized_attrs[:last_name]
26
+ @avatar_url = normalized_attrs[:avatar_url]
27
+ @access_level = normalized_attrs[:access_level]
28
+ @status = normalized_attrs[:status]
29
+ @invited_at = parse_timestamp(normalized_attrs[:invited_at])
30
+ @last_accessed_at = parse_timestamp(normalized_attrs[:last_accessed_at])
31
+ end
32
+
33
+ # Get full name
34
+ def full_name
35
+ [first_name, last_name].compact.join(" ")
36
+ end
37
+
38
+ # Check if member is active
39
+ def active?
40
+ status == "active"
41
+ end
42
+
43
+ # Check if member is invited
44
+ def invited?
45
+ status == "invited"
46
+ end
47
+
48
+ # Check if member is deactivated
49
+ def deactivated?
50
+ status == "deactivated"
51
+ end
52
+
53
+ # Check if member is admin
54
+ def admin?
55
+ access_level == "admin"
56
+ end
57
+
58
+ # Check if member is standard user
59
+ def standard?
60
+ access_level == "standard"
61
+ end
62
+
63
+ # Workspace members cannot be modified via API
64
+ def save(*)
65
+ raise NotImplementedError, "Workspace members cannot be updated via API"
66
+ end
67
+
68
+ def update(*)
69
+ raise NotImplementedError, "Workspace members cannot be updated via API"
70
+ end
71
+
72
+ def destroy(*)
73
+ raise NotImplementedError, "Workspace members cannot be deleted via API"
74
+ end
75
+
76
+ # Convert workspace member to hash representation
77
+ # @return [Hash] Member data as a hash
78
+ def to_h
79
+ super.merge(
80
+ email_address: email_address,
81
+ first_name: first_name,
82
+ last_name: last_name,
83
+ avatar_url: avatar_url,
84
+ access_level: access_level,
85
+ status: status,
86
+ invited_at: invited_at&.iso8601,
87
+ last_accessed_at: last_accessed_at&.iso8601
88
+ ).compact
89
+ end
90
+
91
+ class << self
92
+ # Get the current user (the API key owner)
93
+ def me(**opts)
94
+ # The /v2/workspace_members/me endpoint doesn't exist, use /v2/self instead
95
+ # and then fetch the workspace member details
96
+ self_response = execute_request(:GET, "self", {}, opts)
97
+ member_id = self_response[:authorized_by_workspace_member_id]
98
+ self_response[:workspace_id]
99
+
100
+ # Now fetch the actual workspace member
101
+ members = list(**opts)
102
+ members.find { |m| m.id[:workspace_member_id] == member_id }
103
+ end
104
+ alias_method :current, :me
105
+
106
+ # Find member by email
107
+ def find_by_email(email, **opts)
108
+ list(**opts).find { |member| member.email_address == email } ||
109
+ raise(NotFoundError, "Workspace member with email '#{email}' not found")
110
+ end
111
+
112
+ # List active members only
113
+ def active(**)
114
+ list(**).select(&:active?)
115
+ end
116
+
117
+ # List admin members only
118
+ def admins(**)
119
+ list(**).select(&:admin?)
120
+ end
121
+
122
+ # This resource doesn't support creation, updates, or deletion
123
+ def create(*)
124
+ raise NotImplementedError, "Workspace members cannot be created via API"
125
+ end
126
+
127
+ def update(*)
128
+ raise NotImplementedError, "Workspace members cannot be updated via API"
129
+ end
130
+
131
+ def delete(*)
132
+ raise NotImplementedError, "Workspace members cannot be deleted via API"
133
+ end
134
+ end
135
+ end
136
+ end