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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +164 -0
- data/.simplecov +17 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +333 -0
- data/INTEGRATION_TEST_STATUS.md +149 -0
- data/LICENSE +21 -0
- data/README.md +638 -0
- data/Rakefile +8 -0
- data/attio-ruby.gemspec +61 -0
- data/docs/CODECOV_SETUP.md +34 -0
- data/examples/basic_usage.rb +149 -0
- data/examples/oauth_flow.rb +843 -0
- data/examples/oauth_flow_README.md +84 -0
- data/examples/typed_records_example.rb +167 -0
- data/examples/webhook_server.rb +463 -0
- data/lib/attio/api_resource.rb +539 -0
- data/lib/attio/builders/name_builder.rb +181 -0
- data/lib/attio/client.rb +160 -0
- data/lib/attio/errors.rb +126 -0
- data/lib/attio/internal/record.rb +359 -0
- data/lib/attio/oauth/client.rb +219 -0
- data/lib/attio/oauth/scope_validator.rb +162 -0
- data/lib/attio/oauth/token.rb +158 -0
- data/lib/attio/resources/attribute.rb +332 -0
- data/lib/attio/resources/comment.rb +114 -0
- data/lib/attio/resources/company.rb +224 -0
- data/lib/attio/resources/entry.rb +208 -0
- data/lib/attio/resources/list.rb +196 -0
- data/lib/attio/resources/meta.rb +113 -0
- data/lib/attio/resources/note.rb +213 -0
- data/lib/attio/resources/object.rb +66 -0
- data/lib/attio/resources/person.rb +294 -0
- data/lib/attio/resources/task.rb +147 -0
- data/lib/attio/resources/thread.rb +99 -0
- data/lib/attio/resources/typed_record.rb +98 -0
- data/lib/attio/resources/webhook.rb +224 -0
- data/lib/attio/resources/workspace_member.rb +136 -0
- data/lib/attio/util/configuration.rb +166 -0
- data/lib/attio/util/id_extractor.rb +115 -0
- data/lib/attio/util/webhook_signature.rb +175 -0
- data/lib/attio/version.rb +6 -0
- data/lib/attio/webhook/event.rb +114 -0
- data/lib/attio/webhook/signature_verifier.rb +73 -0
- data/lib/attio.rb +123 -0
- 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
|