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,332 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../api_resource"
|
4
|
+
|
5
|
+
module Attio
|
6
|
+
# Represents a custom attribute on an Attio object
|
7
|
+
# Attributes define the schema for data stored on records
|
8
|
+
class Attribute < APIResource
|
9
|
+
api_operations :list, :retrieve, :create, :update
|
10
|
+
|
11
|
+
# Attribute types
|
12
|
+
TYPES = %w[
|
13
|
+
text
|
14
|
+
number
|
15
|
+
checkbox
|
16
|
+
date
|
17
|
+
timestamp
|
18
|
+
rating
|
19
|
+
currency
|
20
|
+
status
|
21
|
+
select
|
22
|
+
multiselect
|
23
|
+
email
|
24
|
+
phone
|
25
|
+
url
|
26
|
+
user
|
27
|
+
record_reference
|
28
|
+
location
|
29
|
+
].freeze
|
30
|
+
|
31
|
+
# Type configurations
|
32
|
+
TYPE_CONFIGS = {
|
33
|
+
"text" => {supports_default: true, supports_required: true},
|
34
|
+
"number" => {supports_default: true, supports_required: true, supports_unique: true},
|
35
|
+
"checkbox" => {supports_default: true},
|
36
|
+
"date" => {supports_default: true, supports_required: true},
|
37
|
+
"timestamp" => {supports_default: true, supports_required: true},
|
38
|
+
"rating" => {supports_default: true, max_value: 5},
|
39
|
+
"currency" => {supports_default: true, supports_required: true},
|
40
|
+
"status" => {requires_options: true},
|
41
|
+
"select" => {requires_options: true, supports_default: true},
|
42
|
+
"multiselect" => {requires_options: true},
|
43
|
+
"email" => {supports_unique: true, supports_required: true},
|
44
|
+
"phone" => {supports_required: true},
|
45
|
+
"url" => {supports_required: true},
|
46
|
+
"user" => {supports_required: true},
|
47
|
+
"record_reference" => {requires_target_object: true, supports_required: true},
|
48
|
+
"location" => {supports_required: true}
|
49
|
+
}.freeze
|
50
|
+
|
51
|
+
# API endpoint path for attributes
|
52
|
+
# @return [String] The API path
|
53
|
+
def self.resource_path
|
54
|
+
"attributes"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Define known attributes with proper accessors
|
58
|
+
attr_attio :name, :description, :is_required, :is_unique,
|
59
|
+
:is_default_value_enabled, :default_value, :options
|
60
|
+
|
61
|
+
# Read-only attributes
|
62
|
+
attr_reader :api_slug, :type, :attio_object_id, :object_api_slug,
|
63
|
+
:parent_object_id, :created_by_actor, :is_archived, :archived_at,
|
64
|
+
:title
|
65
|
+
|
66
|
+
def initialize(attributes = {}, opts = {})
|
67
|
+
super
|
68
|
+
normalized_attrs = normalize_attributes(attributes)
|
69
|
+
@api_slug = normalized_attrs[:api_slug]
|
70
|
+
@type = normalized_attrs[:type]
|
71
|
+
@attio_object_id = normalized_attrs[:object_id]
|
72
|
+
@object_api_slug = normalized_attrs[:object_api_slug]
|
73
|
+
@parent_object_id = normalized_attrs[:parent_object_id]
|
74
|
+
@created_by_actor = normalized_attrs[:created_by_actor]
|
75
|
+
@is_archived = normalized_attrs[:is_archived] || false
|
76
|
+
@archived_at = parse_timestamp(normalized_attrs[:archived_at])
|
77
|
+
@title = normalized_attrs[:title]
|
78
|
+
end
|
79
|
+
|
80
|
+
# Archive this attribute
|
81
|
+
def archive(**opts)
|
82
|
+
raise InvalidRequestError, "Cannot archive an attribute without an ID" unless persisted?
|
83
|
+
|
84
|
+
response = self.class.send(:execute_request, :POST, "#{resource_path}/archive", {}, opts)
|
85
|
+
update_from(response[:data] || response)
|
86
|
+
self
|
87
|
+
end
|
88
|
+
|
89
|
+
# Unarchive this attribute
|
90
|
+
def unarchive(**opts)
|
91
|
+
raise InvalidRequestError, "Cannot unarchive an attribute without an ID" unless persisted?
|
92
|
+
|
93
|
+
response = self.class.send(:execute_request, :POST, "#{resource_path}/unarchive", {}, opts)
|
94
|
+
update_from(response[:data] || response)
|
95
|
+
self
|
96
|
+
end
|
97
|
+
|
98
|
+
def archived?
|
99
|
+
@is_archived == true
|
100
|
+
end
|
101
|
+
|
102
|
+
def required?
|
103
|
+
is_required == true
|
104
|
+
end
|
105
|
+
|
106
|
+
def unique?
|
107
|
+
is_unique == true
|
108
|
+
end
|
109
|
+
|
110
|
+
def has_default?
|
111
|
+
is_default_value_enabled == true
|
112
|
+
end
|
113
|
+
|
114
|
+
# Convert attribute to hash representation
|
115
|
+
# @return [Hash] Attribute data as a hash
|
116
|
+
def to_h
|
117
|
+
super.merge(
|
118
|
+
api_slug: api_slug,
|
119
|
+
name: name,
|
120
|
+
description: description,
|
121
|
+
type: type,
|
122
|
+
is_required: is_required,
|
123
|
+
is_unique: is_unique,
|
124
|
+
is_default_value_enabled: is_default_value_enabled,
|
125
|
+
default_value: default_value,
|
126
|
+
options: options,
|
127
|
+
object_id: attio_object_id,
|
128
|
+
object_api_slug: object_api_slug,
|
129
|
+
parent_object_id: parent_object_id,
|
130
|
+
created_by_actor: created_by_actor,
|
131
|
+
is_archived: is_archived,
|
132
|
+
archived_at: archived_at&.iso8601
|
133
|
+
).compact
|
134
|
+
end
|
135
|
+
|
136
|
+
def resource_path
|
137
|
+
raise InvalidRequestError, "Cannot generate path without an ID" unless persisted?
|
138
|
+
attribute_id = Util::IdExtractor.extract_for_resource(id, :attribute)
|
139
|
+
"#{self.class.resource_path}/#{attribute_id}"
|
140
|
+
end
|
141
|
+
|
142
|
+
# Override save to handle nested ID
|
143
|
+
def save(**)
|
144
|
+
raise InvalidRequestError, "Cannot save an attribute without an ID" unless persisted?
|
145
|
+
return self unless changed?
|
146
|
+
|
147
|
+
# Pass the full ID (including object context) to update method
|
148
|
+
self.class.update(id, changed_attributes, **)
|
149
|
+
end
|
150
|
+
|
151
|
+
class << self
|
152
|
+
# Override retrieve to handle object-scoped attributes
|
153
|
+
def retrieve(id, **opts)
|
154
|
+
# Extract simple ID if it's a nested hash
|
155
|
+
attribute_id = Util::IdExtractor.extract_for_resource(id, :attribute)
|
156
|
+
validate_id!(attribute_id)
|
157
|
+
|
158
|
+
# For attributes, we need the object context - check if it's in the nested ID
|
159
|
+
if id.is_a?(Hash) && id["object_id"]
|
160
|
+
object_id = id["object_id"]
|
161
|
+
response = execute_request(:GET, "objects/#{object_id}/attributes/#{attribute_id}", {}, opts)
|
162
|
+
else
|
163
|
+
# Fall back to regular attributes endpoint
|
164
|
+
response = execute_request(:GET, "#{resource_path}/#{attribute_id}", {}, opts)
|
165
|
+
end
|
166
|
+
|
167
|
+
new(response["data"] || response, opts)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Override update to handle object-scoped attributes
|
171
|
+
def update(id, params = {}, **opts)
|
172
|
+
# Extract simple ID if it's a nested hash
|
173
|
+
attribute_id = Util::IdExtractor.extract_for_resource(id, :attribute)
|
174
|
+
validate_id!(attribute_id)
|
175
|
+
|
176
|
+
# For attributes, we need the object context
|
177
|
+
if id.is_a?(Hash) && id["object_id"]
|
178
|
+
object_id = id["object_id"]
|
179
|
+
prepared_params = prepare_params_for_update(params)
|
180
|
+
response = execute_request(:PATCH, "objects/#{object_id}/attributes/#{attribute_id}", prepared_params, opts)
|
181
|
+
else
|
182
|
+
# Fall back to regular attributes endpoint
|
183
|
+
prepared_params = prepare_params_for_update(params)
|
184
|
+
response = execute_request(:PATCH, "#{resource_path}/#{attribute_id}", prepared_params, opts)
|
185
|
+
end
|
186
|
+
|
187
|
+
new(response["data"] || response, opts)
|
188
|
+
end
|
189
|
+
|
190
|
+
# Override create to handle validation and object parameter
|
191
|
+
def prepare_params_for_create(params)
|
192
|
+
validate_type!(params[:type])
|
193
|
+
validate_type_config!(params)
|
194
|
+
|
195
|
+
# Generate api_slug from name if not provided
|
196
|
+
api_slug = params[:api_slug] || params[:name].downcase.gsub(/[^a-z0-9]+/, "_")
|
197
|
+
|
198
|
+
{
|
199
|
+
data: {
|
200
|
+
title: params[:name] || params[:title],
|
201
|
+
api_slug: api_slug,
|
202
|
+
type: params[:type],
|
203
|
+
description: params[:description],
|
204
|
+
is_required: params[:is_required] || false,
|
205
|
+
is_unique: params[:is_unique] || false,
|
206
|
+
is_multiselect: params[:is_multiselect] || false,
|
207
|
+
default_value: params[:default_value],
|
208
|
+
config: params[:config] || {}
|
209
|
+
}.compact
|
210
|
+
}
|
211
|
+
end
|
212
|
+
|
213
|
+
# Override update params preparation
|
214
|
+
def prepare_params_for_update(params)
|
215
|
+
# Only certain fields can be updated
|
216
|
+
updateable_fields = %i[
|
217
|
+
name
|
218
|
+
title
|
219
|
+
description
|
220
|
+
is_required
|
221
|
+
is_unique
|
222
|
+
default_value
|
223
|
+
options
|
224
|
+
]
|
225
|
+
|
226
|
+
update_params = params.slice(*updateable_fields)
|
227
|
+
update_params[:options] = prepare_options(update_params[:options]) if update_params[:options]
|
228
|
+
|
229
|
+
# Wrap in data for API
|
230
|
+
{
|
231
|
+
data: update_params
|
232
|
+
}
|
233
|
+
end
|
234
|
+
|
235
|
+
# Override list to handle object-specific attributes
|
236
|
+
def list(params = {}, **opts)
|
237
|
+
if params[:object]
|
238
|
+
object = params.delete(:object)
|
239
|
+
validate_object_identifier!(object)
|
240
|
+
|
241
|
+
response = execute_request(:GET, "objects/#{object}/attributes", params, opts)
|
242
|
+
APIResource::ListObject.new(response, self, params.merge(object: object), opts)
|
243
|
+
else
|
244
|
+
raise ArgumentError, "Attributes must be listed for a specific object. Use Attribute.for_object(object_slug) or pass object: parameter"
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
# Override create to handle object-specific attributes
|
249
|
+
def create(params = {}, **opts)
|
250
|
+
object = params[:object]
|
251
|
+
validate_object_identifier!(object)
|
252
|
+
|
253
|
+
prepared_params = prepare_params_for_create(params)
|
254
|
+
response = execute_request(:POST, "objects/#{object}/attributes", prepared_params, opts)
|
255
|
+
new(response["data"] || response, opts)
|
256
|
+
end
|
257
|
+
|
258
|
+
# List attributes for a specific object
|
259
|
+
def for_object(object, params = {}, **)
|
260
|
+
list(params.merge(object: object), **)
|
261
|
+
end
|
262
|
+
|
263
|
+
private
|
264
|
+
|
265
|
+
def validate_object_identifier!(object)
|
266
|
+
raise ArgumentError, "Object identifier is required" if object.nil? || object.to_s.empty?
|
267
|
+
end
|
268
|
+
|
269
|
+
def validate_type!(type)
|
270
|
+
raise ArgumentError, "Attribute type is required" if type.nil? || type.to_s.empty?
|
271
|
+
unless TYPES.include?(type.to_s)
|
272
|
+
raise ArgumentError, "Invalid attribute type: #{type}. Valid types: #{TYPES.join(", ")}"
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
def validate_type_config!(params)
|
277
|
+
type = params[:type]
|
278
|
+
config = TYPE_CONFIGS[type.to_s]
|
279
|
+
return unless config
|
280
|
+
|
281
|
+
# Check required options
|
282
|
+
if config[:requires_options]
|
283
|
+
options = params[:options]
|
284
|
+
if options.nil? || (options.is_a?(Array) && options.empty?)
|
285
|
+
raise ArgumentError, "Attribute type '#{type}' requires options"
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
# Check required target object
|
290
|
+
if config[:requires_target_object]
|
291
|
+
target = params[:target_object]
|
292
|
+
if target.nil? || target.to_s.empty?
|
293
|
+
raise ArgumentError, "Attribute type '#{type}' requires target_object"
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
# Validate unsupported features
|
298
|
+
if params[:is_unique] && !config[:supports_unique]
|
299
|
+
raise ArgumentError, "Attribute type '#{type}' does not support unique constraint"
|
300
|
+
end
|
301
|
+
|
302
|
+
if params[:is_required] && !config[:supports_required]
|
303
|
+
raise ArgumentError, "Attribute type '#{type}' does not support required constraint"
|
304
|
+
end
|
305
|
+
|
306
|
+
if params[:is_default_value_enabled] && !config[:supports_default]
|
307
|
+
raise ArgumentError, "Attribute type '#{type}' does not support default values"
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
def prepare_options(options)
|
312
|
+
return nil unless options
|
313
|
+
|
314
|
+
case options
|
315
|
+
when Array
|
316
|
+
options.map do |opt|
|
317
|
+
case opt
|
318
|
+
when String
|
319
|
+
{title: opt}
|
320
|
+
when Hash
|
321
|
+
opt
|
322
|
+
else
|
323
|
+
{title: opt.to_s}
|
324
|
+
end
|
325
|
+
end
|
326
|
+
else
|
327
|
+
options
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../api_resource"
|
4
|
+
|
5
|
+
module Attio
|
6
|
+
# Represents a comment in an Attio thread
|
7
|
+
# Comments are immutable once created
|
8
|
+
class Comment < APIResource
|
9
|
+
# Comments only support create, retrieve, and delete (no list or update)
|
10
|
+
api_operations :retrieve, :delete
|
11
|
+
|
12
|
+
# API endpoint path for comments
|
13
|
+
# @return [String] The API path
|
14
|
+
def self.resource_path
|
15
|
+
"comments"
|
16
|
+
end
|
17
|
+
|
18
|
+
# Custom create implementation
|
19
|
+
def self.create(content: nil, format: "plaintext", author: nil, thread_id: nil, created_at: nil, **opts)
|
20
|
+
raise ArgumentError, "Content is required" if content.nil? || content.to_s.empty?
|
21
|
+
raise ArgumentError, "Thread ID is required" if thread_id.nil? || thread_id.to_s.empty?
|
22
|
+
raise ArgumentError, "Author is required" if author.nil?
|
23
|
+
|
24
|
+
request_params = {
|
25
|
+
data: {
|
26
|
+
format: format,
|
27
|
+
content: content,
|
28
|
+
author: author,
|
29
|
+
thread_id: thread_id
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
# Only add created_at if provided
|
34
|
+
request_params[:data][:created_at] = created_at if created_at
|
35
|
+
|
36
|
+
response = execute_request(:POST, resource_path, request_params, opts)
|
37
|
+
new(response["data"] || response, opts)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Define attribute accessors
|
41
|
+
attr_attio :content_plaintext, :thread_id, :author, :record, :entry, :resolved_by
|
42
|
+
|
43
|
+
# Parse resolved_at as Time
|
44
|
+
def resolved_at
|
45
|
+
value = @attributes[:resolved_at]
|
46
|
+
return nil if value.nil?
|
47
|
+
|
48
|
+
case value
|
49
|
+
when Time
|
50
|
+
value
|
51
|
+
when String
|
52
|
+
Time.parse(value)
|
53
|
+
else
|
54
|
+
value
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Comments are immutable
|
59
|
+
def immutable?
|
60
|
+
true
|
61
|
+
end
|
62
|
+
|
63
|
+
# Override save to raise error since comments are immutable
|
64
|
+
def save(**opts)
|
65
|
+
raise InvalidRequestError, "Comments are immutable and cannot be updated"
|
66
|
+
end
|
67
|
+
|
68
|
+
# Override destroy to use the correct comment ID
|
69
|
+
def destroy(**opts)
|
70
|
+
raise InvalidRequestError, "Cannot destroy a comment without an ID" unless persisted?
|
71
|
+
|
72
|
+
comment_id = extract_comment_id
|
73
|
+
self.class.send(:execute_request, :DELETE, "#{self.class.resource_path}/#{comment_id}", {}, opts)
|
74
|
+
@attributes.clear
|
75
|
+
@changed_attributes.clear
|
76
|
+
@id = nil
|
77
|
+
true
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def extract_comment_id
|
83
|
+
case id
|
84
|
+
when Hash
|
85
|
+
id[:comment_id] || id["comment_id"]
|
86
|
+
else
|
87
|
+
id
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def resource_path
|
92
|
+
comment_id = extract_comment_id
|
93
|
+
"#{self.class.resource_path}/#{comment_id}"
|
94
|
+
end
|
95
|
+
|
96
|
+
def to_h
|
97
|
+
{
|
98
|
+
id: id,
|
99
|
+
thread_id: thread_id,
|
100
|
+
content_plaintext: content_plaintext,
|
101
|
+
entry: entry,
|
102
|
+
record: record,
|
103
|
+
resolved_at: resolved_at&.iso8601,
|
104
|
+
resolved_by: resolved_by,
|
105
|
+
created_at: created_at&.iso8601,
|
106
|
+
author: author
|
107
|
+
}.compact
|
108
|
+
end
|
109
|
+
|
110
|
+
def inspect
|
111
|
+
"#<#{self.class.name}:#{object_id} id=#{id.inspect} thread=#{thread_id} content=#{content_plaintext&.truncate(30).inspect}>"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,224 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "typed_record"
|
4
|
+
|
5
|
+
module Attio
|
6
|
+
# Represents a company record in Attio
|
7
|
+
# Provides convenient methods for working with companies and their attributes
|
8
|
+
class Company < TypedRecord
|
9
|
+
object_type "companies"
|
10
|
+
|
11
|
+
# Set the company name (much simpler than person names!)
|
12
|
+
# @param name [String] The company name
|
13
|
+
def name=(name)
|
14
|
+
self[:name] = name
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get the company name
|
18
|
+
# @return [String, nil] The company name or nil if not set
|
19
|
+
def name
|
20
|
+
self[:name]
|
21
|
+
end
|
22
|
+
|
23
|
+
# Add a domain
|
24
|
+
# @param domain [String] The domain to add (e.g., "example.com")
|
25
|
+
def add_domain(domain)
|
26
|
+
domains = self[:domains] || []
|
27
|
+
# Ensure it's an array
|
28
|
+
domains = [domains] unless domains.is_a?(Array)
|
29
|
+
|
30
|
+
# Normalize domain (remove protocol if present)
|
31
|
+
domain = domain.sub(/^https?:\/\//, "")
|
32
|
+
|
33
|
+
# Check if domain already exists
|
34
|
+
exists = domains.any? { |d|
|
35
|
+
d.is_a?(Hash) ? (d["domain"] == domain || d[:domain] == domain) : d == domain
|
36
|
+
}
|
37
|
+
|
38
|
+
unless exists
|
39
|
+
# Extract just the domain strings if we have hashes
|
40
|
+
domain_strings = domains.filter_map { |d|
|
41
|
+
d.is_a?(Hash) ? (d["domain"] || d[:domain]) : d
|
42
|
+
}
|
43
|
+
|
44
|
+
# Add the new domain
|
45
|
+
domain_strings << domain
|
46
|
+
|
47
|
+
# Set as simple array of strings
|
48
|
+
self[:domains] = domain_strings
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get the primary domain
|
53
|
+
# @return [String, nil] The primary domain or nil if not set
|
54
|
+
def domain
|
55
|
+
domains = self[:domains]
|
56
|
+
return nil unless domains
|
57
|
+
|
58
|
+
extract_primary_value(domains, "domain")
|
59
|
+
end
|
60
|
+
|
61
|
+
# Get all domains
|
62
|
+
# @return [Array<String>] Array of domain strings
|
63
|
+
def domains_list
|
64
|
+
domains = self[:domains]
|
65
|
+
return [] unless domains
|
66
|
+
|
67
|
+
case domains
|
68
|
+
when Array
|
69
|
+
domains.filter_map { |d| extract_field_value(d, "domain") }
|
70
|
+
else
|
71
|
+
[domain].compact
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
# Extract primary value from various data structures
|
78
|
+
# @param value [Array, Hash, Object] The value to extract from
|
79
|
+
# @param field [String] The field name for hash extraction
|
80
|
+
# @return [String, nil] The extracted value
|
81
|
+
def extract_primary_value(value, field)
|
82
|
+
case value
|
83
|
+
when Array
|
84
|
+
return nil if value.empty?
|
85
|
+
extract_field_value(value.first, field)
|
86
|
+
when Hash
|
87
|
+
value[field] || value[field.to_sym]
|
88
|
+
else
|
89
|
+
value.to_s
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Extract a value from a hash or convert to string
|
94
|
+
# @param item [Hash, Object] The item to extract from
|
95
|
+
# @param field [String] The field name for hash extraction
|
96
|
+
# @return [String] The extracted value
|
97
|
+
def extract_field_value(item, field)
|
98
|
+
case item
|
99
|
+
when Hash
|
100
|
+
item[field] || item[field.to_sym]
|
101
|
+
else
|
102
|
+
item.to_s
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
public
|
107
|
+
|
108
|
+
# Set the company description
|
109
|
+
# @param description [String] The company description
|
110
|
+
def description=(desc)
|
111
|
+
self[:description] = desc
|
112
|
+
end
|
113
|
+
|
114
|
+
# Set the employee count
|
115
|
+
# @param count [Integer, String] The employee count or range (e.g., "10-50")
|
116
|
+
def employee_count=(count)
|
117
|
+
self[:employee_count] = count.to_s
|
118
|
+
end
|
119
|
+
|
120
|
+
# Add a team member (person) to this company
|
121
|
+
# @param person [Person, String] A Person instance or person ID
|
122
|
+
def add_team_member(person)
|
123
|
+
# This would typically be done from the Person side
|
124
|
+
# but we can provide a convenience method
|
125
|
+
if person.is_a?(Person)
|
126
|
+
person.company = self
|
127
|
+
person.save
|
128
|
+
elsif person.is_a?(String)
|
129
|
+
# If it's an ID, we need to fetch and update the person
|
130
|
+
retrieved_person = Person.retrieve(person)
|
131
|
+
retrieved_person.company = self
|
132
|
+
retrieved_person.save
|
133
|
+
else
|
134
|
+
raise ArgumentError, "Team member must be a Person instance or ID string"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Get all people associated with this company
|
139
|
+
# @return [Attio::ListObject] List of people
|
140
|
+
def team_members(**opts)
|
141
|
+
company_id = id.is_a?(Hash) ? id["record_id"] : id
|
142
|
+
Person.list(**opts.merge(params: {
|
143
|
+
filter: {
|
144
|
+
company: {"$references": company_id}
|
145
|
+
}
|
146
|
+
}))
|
147
|
+
end
|
148
|
+
|
149
|
+
class << self
|
150
|
+
# Create a company with a simplified interface
|
151
|
+
# @param attributes [Hash] Company attributes
|
152
|
+
# @option attributes [String] :name Company name (required)
|
153
|
+
# @option attributes [String, Array<String>] :domain Domain(s)
|
154
|
+
# @option attributes [String] :description Company description
|
155
|
+
# @option attributes [String, Integer] :employee_count Employee count
|
156
|
+
# @option attributes [Hash] :values Raw values hash (for advanced use)
|
157
|
+
def create(name:, domain: nil, domains: nil, description: nil,
|
158
|
+
employee_count: nil, values: {}, **opts)
|
159
|
+
# Name is required and simple for companies
|
160
|
+
values[:name] = name
|
161
|
+
|
162
|
+
# Handle domains
|
163
|
+
if domain || domains
|
164
|
+
domain_list = []
|
165
|
+
domain_list << domain if domain
|
166
|
+
domain_list += Array(domains) if domains
|
167
|
+
values[:domains] = domain_list.uniq unless domain_list.empty?
|
168
|
+
end
|
169
|
+
|
170
|
+
values[:description] = description if description
|
171
|
+
values[:employee_count] = employee_count.to_s if employee_count
|
172
|
+
|
173
|
+
super(values: values, **opts)
|
174
|
+
end
|
175
|
+
|
176
|
+
# Find a company by domain
|
177
|
+
# @param domain [String] Domain to search for
|
178
|
+
def find_by_domain(domain, **opts)
|
179
|
+
# Normalize domain
|
180
|
+
domain = domain.sub(/^https?:\/\//, "")
|
181
|
+
|
182
|
+
list(**opts.merge(
|
183
|
+
filter: {
|
184
|
+
domains: {
|
185
|
+
domain: {
|
186
|
+
"$eq": domain
|
187
|
+
}
|
188
|
+
}
|
189
|
+
}
|
190
|
+
)).first
|
191
|
+
end
|
192
|
+
|
193
|
+
# Find companies by name
|
194
|
+
# @param name [String] Name to search for
|
195
|
+
def find_by_name(name, **opts)
|
196
|
+
results = search(name, **opts)
|
197
|
+
results.first
|
198
|
+
end
|
199
|
+
|
200
|
+
# Find companies by employee count range
|
201
|
+
# @param min [Integer] Minimum employee count
|
202
|
+
# @param max [Integer] Maximum employee count (optional)
|
203
|
+
def find_by_size(min, max = nil, **opts)
|
204
|
+
filter = if max
|
205
|
+
{
|
206
|
+
employee_count: {
|
207
|
+
"$gte": min.to_s,
|
208
|
+
"$lte": max.to_s
|
209
|
+
}
|
210
|
+
}
|
211
|
+
else
|
212
|
+
{
|
213
|
+
employee_count: {"$gte": min.to_s}
|
214
|
+
}
|
215
|
+
end
|
216
|
+
|
217
|
+
list(**opts.merge(params: {filter: filter}))
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Convenience alias
|
223
|
+
Companies = Company
|
224
|
+
end
|