attio-ruby 0.1.0 → 0.1.2

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.
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typed_record"
4
+
5
+ module Attio
6
+ # Represents a Deal record in Attio
7
+ #
8
+ # @example Create a deal
9
+ # deal = Attio::Deal.create(
10
+ # name: "Enterprise Deal",
11
+ # value: 50000,
12
+ # stage: "In Progress"
13
+ # )
14
+ #
15
+ # @example Find deals by status
16
+ # open_deals = Attio::Deal.find_by(status: "open")
17
+ # won_deals = Attio::Deal.find_by(status: "won")
18
+ #
19
+ # @example Find high-value deals
20
+ # big_deals = Attio::Deal.find_by_value_range(min: 100000)
21
+ #
22
+ # @example Update deal status
23
+ # deal.update_status("won")
24
+ #
25
+ class Deal < TypedRecord
26
+ object_type "deals"
27
+
28
+ class << self
29
+ # Create a deal with a simplified interface
30
+ # @param attributes [Hash] Deal attributes
31
+ # @option attributes [String] :name Deal name (recommended)
32
+ # @option attributes [Numeric] :value Deal value (recommended)
33
+ # @option attributes [String] :stage Deal stage (recommended) - defaults: "Lead", "In Progress", "Won 🎉", "Lost"
34
+ # @option attributes [String] :status Deal status (alias for stage)
35
+ # @option attributes [String] :owner Owner email or workspace member (recommended)
36
+ # @option attributes [Array<String>] :associated_people Email addresses of associated people
37
+ # @option attributes [Array<String>] :associated_company Domains of associated companies
38
+ # @option attributes [Hash] :values Raw values hash (for advanced use)
39
+ def create(name:, value: nil, stage: nil, status: nil, owner: nil,
40
+ associated_people: nil, associated_company: nil, values: {}, **opts)
41
+ # Name is required and simple
42
+ values[:name] = name if name && !values[:name]
43
+
44
+ # Add optional fields
45
+ values[:value] = value if value && !values[:value]
46
+
47
+ # Handle stage vs status - API uses "stage" but we support both
48
+ if (stage || status) && !values[:stage]
49
+ values[:stage] = stage || status
50
+ end
51
+
52
+
53
+ # Handle owner - can be email address or workspace member reference
54
+ if owner && !values[:owner]
55
+ values[:owner] = owner
56
+ end
57
+
58
+ # Handle associated people - convert email array to proper format
59
+ if associated_people && !values[:associated_people]
60
+ values[:associated_people] = associated_people.map do |email|
61
+ {
62
+ target_object: "people",
63
+ email_addresses: [
64
+ { email_address: email }
65
+ ]
66
+ }
67
+ end
68
+ end
69
+
70
+ # Handle associated company - convert domain array to proper format
71
+ if associated_company && !values[:associated_company]
72
+ # associated_company can be array of domains or single domain
73
+ domains = associated_company.is_a?(Array) ? associated_company : [associated_company]
74
+ values[:associated_company] = {
75
+ target_object: "companies",
76
+ domains: domains.map { |domain| { domain: domain } }
77
+ }
78
+ end
79
+
80
+ super(values: values, **opts)
81
+ end
82
+
83
+ # Find deals by stage names
84
+ # @param stage_names [Array<String>] Array of stage names to filter by
85
+ # @return [Attio::ListObject] List of matching deals
86
+ def in_stage(stage_names:, **opts)
87
+ # If only one stage, use simple equality
88
+ if stage_names.length == 1
89
+ filter = { stage: stage_names.first }
90
+ else
91
+ # Multiple stages need $or operator
92
+ filter = {
93
+ "$or": stage_names.map { |stage| { stage: stage } }
94
+ }
95
+ end
96
+
97
+ list(**opts.merge(params: {filter: filter}))
98
+ end
99
+
100
+ # Find won deals using configured statuses
101
+ # @return [Attio::ListObject] List of won deals
102
+ def won(**opts)
103
+ in_stage(stage_names: Attio.configuration.won_statuses, **opts)
104
+ end
105
+
106
+ # Find lost deals using configured statuses
107
+ # @return [Attio::ListObject] List of lost deals
108
+ def lost(**opts)
109
+ in_stage(stage_names: Attio.configuration.lost_statuses, **opts)
110
+ end
111
+
112
+ # Find open deals (Lead + In Progress) using configured statuses
113
+ # @return [Attio::ListObject] List of open deals
114
+ def open_deals(**opts)
115
+ all_open_statuses = Attio.configuration.open_statuses + Attio.configuration.in_progress_statuses
116
+ in_stage(stage_names: all_open_statuses, **opts)
117
+ end
118
+
119
+ # Find deals within a value range
120
+ # @param min [Numeric] Minimum value (optional)
121
+ # @param max [Numeric] Maximum value (optional)
122
+ # @return [Attio::ListObject] List of matching deals
123
+ def find_by_value_range(min: nil, max: nil, **opts)
124
+ filters = []
125
+ filters << {value: {"$gte": min}} if min
126
+ filters << {value: {"$lte": max}} if max
127
+
128
+ filter = if filters.length == 1
129
+ filters.first
130
+ elsif filters.length > 1
131
+ {"$and": filters}
132
+ else
133
+ {}
134
+ end
135
+
136
+ list(**opts.merge(params: {filter: filter}))
137
+ end
138
+
139
+ # # Find deals closing soon (requires close_date attribute)
140
+ # # @param days [Integer] Number of days from today
141
+ # # @return [Attio::ListObject] List of deals closing soon
142
+ # def closing_soon(days: 30, **opts)
143
+ # today = Date.today
144
+ # end_date = today + days
145
+ #
146
+ # list(**opts.merge(params: {
147
+ # filter: {
148
+ # "$and": [
149
+ # {close_date: {"$gte": today.iso8601}},
150
+ # {close_date: {"$lte": end_date.iso8601}},
151
+ # {stage: {"$ne": "Won 🎉"}},
152
+ # {stage: {"$ne": "Lost"}}
153
+ # ]
154
+ # }
155
+ # }))
156
+ # end
157
+
158
+ # Find deals by owner
159
+ # @param owner_id [String] The workspace member ID
160
+ # @return [Attio::ListObject] List of deals owned by the member
161
+ def find_by_owner(owner_id, **opts)
162
+ list(**opts.merge(params: {
163
+ filter: {
164
+ owner: {
165
+ target_object: "workspace_members",
166
+ target_record_id: owner_id
167
+ }
168
+ }
169
+ }))
170
+ end
171
+
172
+ private
173
+
174
+ # Build filter for status field (maps to stage)
175
+ def filter_by_status(value)
176
+ { stage: value }
177
+ end
178
+ end
179
+
180
+ # Get the deal name
181
+ # @return [String, nil] The deal name
182
+ def name
183
+ self[:name]
184
+ end
185
+
186
+ # Get the deal value
187
+ # @return [Numeric, nil] The deal value
188
+ def value
189
+ self[:value]
190
+ end
191
+
192
+ # Get the deal stage (API uses "stage" but we provide status for compatibility)
193
+ # @return [String, nil] The deal stage
194
+ def stage
195
+ self[:stage]
196
+ end
197
+
198
+ # Alias for stage (for compatibility)
199
+ # @return [String, nil] The deal stage
200
+ def status
201
+ self[:stage]
202
+ end
203
+
204
+ # # Get the close date (if attribute exists)
205
+ # # @return [String, nil] The close date
206
+ # def close_date
207
+ # self[:close_date]
208
+ # end
209
+
210
+ # # Get the probability (if attribute exists)
211
+ # # @return [Numeric, nil] The win probability
212
+ # def probability
213
+ # self[:probability]
214
+ # end
215
+
216
+ # Get the owner reference
217
+ # @return [Hash, nil] The owner reference
218
+ def owner
219
+ self[:owner]
220
+ end
221
+
222
+ # Get the company reference
223
+ # @return [Hash, nil] The company reference
224
+ def company
225
+ self[:company]
226
+ end
227
+
228
+ # Update the deal stage
229
+ # @param new_stage [String] The new stage
230
+ # @return [Attio::Deal] The updated deal
231
+ def update_stage(new_stage, **opts)
232
+ self.class.update(id, values: {stage: new_stage}, **opts)
233
+ end
234
+
235
+ # Alias for update_stage (for compatibility)
236
+ # @param new_status [String] The new status/stage
237
+ # @return [Attio::Deal] The updated deal
238
+ def update_status(new_status, **opts)
239
+ update_stage(new_status, **opts)
240
+ end
241
+
242
+ # # Update the deal probability (if attribute exists)
243
+ # # @param new_probability [Numeric] The new probability (0-100)
244
+ # # @return [Attio::Deal] The updated deal
245
+ # def update_probability(new_probability, **opts)
246
+ # self.class.update(id, values: {probability: new_probability}, **opts)
247
+ # end
248
+
249
+ # Update the deal value
250
+ # @param new_value [Numeric] The new value
251
+ # @return [Attio::Deal] The updated deal
252
+ def update_value(new_value, **opts)
253
+ self.class.update(id, values: {value: new_value}, **opts)
254
+ end
255
+
256
+ # Get the associated company record
257
+ # @return [Attio::Company, nil] The company record if associated
258
+ def company_record(**opts)
259
+ return nil unless company
260
+
261
+ company_id = company.is_a?(Hash) ? company["target_record_id"] : company
262
+ Company.retrieve(company_id, **opts) if company_id
263
+ end
264
+
265
+ # Get the owner workspace member record
266
+ # @return [Attio::WorkspaceMember, nil] The owner record if assigned
267
+ def owner_record(**opts)
268
+ return nil unless owner
269
+
270
+ owner_id = if owner.is_a?(Hash)
271
+ owner["referenced_actor_id"] || owner["target_record_id"]
272
+ else
273
+ owner
274
+ end
275
+ WorkspaceMember.retrieve(owner_id, **opts) if owner_id
276
+ end
277
+
278
+ # # Calculate expected revenue (value * probability / 100)
279
+ # # @return [Numeric, nil] The expected revenue
280
+ # def expected_revenue
281
+ # return nil unless value && probability
282
+ # (value * probability / 100.0).round(2)
283
+ # end
284
+
285
+ # Get the current status title
286
+ # @return [String, nil] The current status title
287
+ def current_status
288
+ return nil unless stage
289
+
290
+ if stage.is_a?(Hash)
291
+ stage.dig("status", "title")
292
+ else
293
+ stage
294
+ end
295
+ end
296
+
297
+ # Get the timestamp when the status changed
298
+ # @return [Time, nil] The timestamp when status changed
299
+ def status_changed_at
300
+ return nil unless stage
301
+
302
+ if stage.is_a?(Hash) && stage["active_from"]
303
+ Time.parse(stage["active_from"])
304
+ else
305
+ nil
306
+ end
307
+ end
308
+
309
+ # Check if the deal is open
310
+ # @return [Boolean] True if the deal is open
311
+ def open?
312
+ return false unless current_status
313
+
314
+ all_open_statuses = Attio.configuration.open_statuses + Attio.configuration.in_progress_statuses
315
+ all_open_statuses.include?(current_status)
316
+ end
317
+
318
+ # Check if the deal is won
319
+ # @return [Boolean] True if the deal is won
320
+ def won?
321
+ return false unless current_status
322
+
323
+ Attio.configuration.won_statuses.include?(current_status)
324
+ end
325
+
326
+ # Check if the deal is lost
327
+ # @return [Boolean] True if the deal is lost
328
+ def lost?
329
+ return false unless current_status
330
+
331
+ Attio.configuration.lost_statuses.include?(current_status)
332
+ end
333
+
334
+ # Get the timestamp when the deal was won
335
+ # @return [Time, nil] The timestamp when deal was won, or nil if not won
336
+ def won_at
337
+ won? ? status_changed_at : nil
338
+ end
339
+
340
+ # Get the timestamp when the deal was closed (won or lost)
341
+ # @return [Time, nil] The timestamp when deal was closed, or nil if still open
342
+ def closed_at
343
+ (won? || lost?) ? status_changed_at : nil
344
+ end
345
+
346
+ # # Check if the deal is overdue
347
+ # # @return [Boolean] True if close date has passed and deal is still open
348
+ # def overdue?
349
+ # return false unless close_date && open?
350
+ # Date.parse(close_date) < Date.today
351
+ # end
352
+ end
353
+
354
+ # Alias for Deal (plural form)
355
+ # @example
356
+ # Attio::Deals.create(name: "New Deal", value: 10000)
357
+ Deals = Deal
358
+ end
@@ -23,47 +23,78 @@ module Attio
23
23
  alias_method :current, :identify
24
24
  end
25
25
 
26
- # Define attribute accessors
27
- attr_attio :workspace, :token, :actor
26
+ # Build workspace object from flat attributes
27
+ def workspace
28
+ return nil unless self[:workspace_id]
29
+
30
+ {
31
+ id: self[:workspace_id],
32
+ name: self[:workspace_name],
33
+ slug: self[:workspace_slug],
34
+ logo_url: self[:workspace_logo_url]
35
+ }.compact
36
+ end
37
+
38
+ # Build token object from flat attributes
39
+ def token
40
+ return nil unless self[:client_id]
41
+
42
+ {
43
+ id: self[:client_id],
44
+ type: self[:token_type] || "Bearer",
45
+ scope: self[:scope]
46
+ }.compact
47
+ end
48
+
49
+ # Build actor object from flat attributes
50
+ def actor
51
+ return nil unless self[:authorized_by_workspace_member_id]
52
+
53
+ {
54
+ type: "workspace-member",
55
+ id: self[:authorized_by_workspace_member_id]
56
+ }
57
+ end
28
58
 
29
59
  # Convenience methods for workspace info
30
60
  def workspace_id
31
- workspace&.dig(:id)
61
+ self[:workspace_id]
32
62
  end
33
63
 
34
64
  # Get the workspace name
35
65
  # @return [String, nil] The workspace name
36
66
  def workspace_name
37
- workspace&.dig(:name)
67
+ self[:workspace_name]
38
68
  end
39
69
 
40
70
  # Get the workspace slug
41
71
  # @return [String, nil] The workspace slug
42
72
  def workspace_slug
43
- workspace&.dig(:slug)
73
+ self[:workspace_slug]
44
74
  end
45
75
 
46
76
  # Convenience methods for token info
47
77
  def token_id
48
- token&.dig(:id)
78
+ self[:client_id]
49
79
  end
50
80
 
51
81
  # Get the token name
52
- # @return [String, nil] The token name
82
+ # @return [String, nil] The token name (not available in flat format)
53
83
  def token_name
54
- token&.dig(:name)
84
+ nil
55
85
  end
56
86
 
57
87
  # Get the token type
58
88
  # @return [String, nil] The token type
59
89
  def token_type
60
- token&.dig(:type)
90
+ self[:token_type]
61
91
  end
62
92
 
63
93
  # Get the token's OAuth scopes
64
94
  # @return [Array<String>] Array of scope strings
65
95
  def scopes
66
- token&.dig(:scopes) || []
96
+ return [] unless self[:scope]
97
+ self[:scope].split(" ")
67
98
  end
68
99
 
69
100
  # Check if token has a specific scope
@@ -96,8 +127,6 @@ module Attio
96
127
  raise InvalidRequestError, "Meta information is read-only"
97
128
  end
98
129
 
99
- private
100
-
101
130
  def to_h
102
131
  {
103
132
  workspace: workspace,
@@ -109,5 +138,7 @@ module Attio
109
138
  def inspect
110
139
  "#<#{self.class.name}:#{object_id} workspace=#{workspace_slug.inspect} token=#{token_name.inspect}>"
111
140
  end
141
+
142
+ private
112
143
  end
113
144
  end
@@ -45,11 +45,31 @@ module Attio
45
45
  Internal::Record.create(object: api_slug || id, values: values, **)
46
46
  end
47
47
 
48
- # Find by API slug
48
+ # Find by attribute using Rails-style syntax
49
+ def self.find_by(**conditions)
50
+ # Extract any opts that aren't conditions
51
+ opts = {}
52
+ known_opts = [:api_key, :timeout, :idempotency_key]
53
+ known_opts.each do |opt|
54
+ opts[opt] = conditions.delete(opt) if conditions.key?(opt)
55
+ end
56
+
57
+ # Currently only supports slug
58
+ if conditions.key?(:slug)
59
+ slug = conditions[:slug]
60
+ begin
61
+ retrieve(slug, **opts)
62
+ rescue NotFoundError
63
+ list(**opts).find { |obj| obj.api_slug == slug }
64
+ end
65
+ else
66
+ raise ArgumentError, "find_by only supports slug attribute for objects"
67
+ end
68
+ end
69
+
70
+ # Find by API slug (deprecated - use find_by(slug: ...) instead)
49
71
  def self.find_by_slug(slug, **opts)
50
- retrieve(slug, **opts)
51
- rescue NotFoundError
52
- list(**opts).find { |obj| obj.api_slug == slug }
72
+ find_by(slug: slug, **opts)
53
73
  end
54
74
 
55
75
  # Get standard objects
@@ -251,19 +251,6 @@ module Attio
251
251
  super(values: values, **opts)
252
252
  end
253
253
 
254
- # Find people by email
255
- # @param email [String] Email address to search for
256
- def find_by_email(email, **opts)
257
- list(**opts.merge(
258
- filter: {
259
- email_addresses: {
260
- email_address: {
261
- "$eq": email
262
- }
263
- }
264
- }
265
- )).first
266
- end
267
254
 
268
255
  # Search people by query
269
256
  # @param query [String] Query to search for
@@ -280,11 +267,28 @@ module Attio
280
267
  ))
281
268
  end
282
269
 
283
- # Find people by name
284
- # @param name [String] Name to search for
285
- def find_by_name(name, **opts)
286
- results = search(name, **opts)
287
- results.first
270
+ private
271
+
272
+ # Build filter for email field
273
+ def filter_by_email(value)
274
+ {
275
+ email_addresses: {
276
+ email_address: {
277
+ "$eq": value
278
+ }
279
+ }
280
+ }
281
+ end
282
+
283
+ # Build filter for name field (searches across first, last, and full name)
284
+ def filter_by_name(value)
285
+ {
286
+ "$or": [
287
+ {name: {first_name: {"$contains": value}}},
288
+ {name: {last_name: {"$contains": value}}},
289
+ {name: {full_name: {"$contains": value}}}
290
+ ]
291
+ }
288
292
  end
289
293
  end
290
294
  end
@@ -61,12 +61,55 @@ module Attio
61
61
  end
62
62
 
63
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
64
+ # Supports Rails-style hash syntax: find_by(name: "Test")
65
+ def find_by(**conditions)
66
+ raise ArgumentError, "find_by requires at least one condition" if conditions.empty?
67
+
68
+ # Extract any opts that aren't conditions (like api_key)
69
+ opts = {}
70
+ known_opts = [:api_key, :timeout, :idempotency_key]
71
+ known_opts.each do |opt|
72
+ opts[opt] = conditions.delete(opt) if conditions.key?(opt)
73
+ end
74
+
75
+ # Build filter from conditions
76
+ filters = []
77
+ search_query = nil
78
+
79
+ conditions.each do |field, value|
80
+ # Check if there's a special filter method for this field
81
+ filter_method = "filter_by_#{field}"
82
+ if respond_to?(filter_method, true) # true = include private methods
83
+ result = send(filter_method, value)
84
+ # Check if this should be a search instead of a filter
85
+ if result == :use_search
86
+ search_query = value
87
+ else
88
+ filters << result
89
+ end
90
+ else
91
+ # Use the field as-is
92
+ filters << {field => value}
93
+ end
94
+ end
95
+
96
+ # If we have a search query, use search instead of filter
97
+ if search_query
98
+ search(search_query, **opts).first
99
+ else
100
+ # Combine multiple filters with $and if needed
101
+ final_filter = if filters.length == 1
102
+ filters.first
103
+ elsif filters.length > 1
104
+ {"$and": filters}
105
+ else
106
+ {}
107
+ end
108
+
109
+ list(**opts.merge(params: {
110
+ filter: final_filter
111
+ })).first
112
+ end
70
113
  end
71
114
  end
72
115
 
@@ -103,20 +103,33 @@ module Attio
103
103
  end
104
104
  alias_method :current, :me
105
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")
106
+ # Find member by attribute using Rails-style syntax
107
+ def find_by(**conditions)
108
+ # Extract any opts that aren't conditions
109
+ opts = {}
110
+ known_opts = [:api_key, :timeout, :idempotency_key]
111
+ known_opts.each do |opt|
112
+ opts[opt] = conditions.delete(opt) if conditions.key?(opt)
113
+ end
114
+
115
+ # Currently only supports email
116
+ if conditions.key?(:email)
117
+ email = conditions[:email]
118
+ list(**opts).find { |member| member.email_address == email } ||
119
+ raise(NotFoundError, "Workspace member with email '#{email}' not found")
120
+ else
121
+ raise ArgumentError, "find_by only supports email attribute for workspace members"
122
+ end
110
123
  end
111
124
 
112
125
  # List active members only
113
- def active(**)
114
- list(**).select(&:active?)
126
+ def active(**opts)
127
+ list(**opts).select(&:active?)
115
128
  end
116
129
 
117
130
  # List admin members only
118
- def admins(**)
119
- list(**).select(&:admin?)
131
+ def admins(**opts)
132
+ list(**opts).select(&:admin?)
120
133
  end
121
134
 
122
135
  # This resource doesn't support creation, updates, or deletion
@@ -22,6 +22,10 @@ module Attio
22
22
  ca_bundle_path
23
23
  verify_ssl_certs
24
24
  use_faraday
25
+ won_statuses
26
+ lost_statuses
27
+ open_statuses
28
+ in_progress_statuses
25
29
  ].freeze
26
30
 
27
31
  # All available configuration settings
@@ -38,7 +42,11 @@ module Attio
38
42
  debug: false,
39
43
  ca_bundle_path: nil,
40
44
  verify_ssl_certs: true,
41
- use_faraday: true
45
+ use_faraday: true,
46
+ won_statuses: ["Won 🎉"].freeze,
47
+ lost_statuses: ["Lost"].freeze,
48
+ open_statuses: ["Lead"].freeze,
49
+ in_progress_statuses: ["In Progress"].freeze
42
50
  }.freeze
43
51
 
44
52
  attr_reader(*ALL_SETTINGS)
@@ -157,7 +165,9 @@ module Attio
157
165
 
158
166
  def reset_without_lock!
159
167
  DEFAULT_SETTINGS.each do |key, value|
160
- instance_variable_set("@#{key}", value)
168
+ # For arrays, create a new copy to avoid frozen arrays
169
+ actual_value = value.is_a?(Array) ? value.dup : value
170
+ instance_variable_set("@#{key}", actual_value)
161
171
  end
162
172
  @api_key = nil
163
173
  end
data/lib/attio/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Attio
4
4
  # Current version of the Attio Ruby gem
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.2"
6
6
  end