attio-ruby 0.1.0 → 0.1.1

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,288 @@
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 within a value range
84
+ # @param min [Numeric] Minimum value (optional)
85
+ # @param max [Numeric] Maximum value (optional)
86
+ # @return [Attio::ListObject] List of matching deals
87
+ def find_by_value_range(min: nil, max: nil, **opts)
88
+ filters = []
89
+ filters << {value: {"$gte": min}} if min
90
+ filters << {value: {"$lte": max}} if max
91
+
92
+ filter = if filters.length == 1
93
+ filters.first
94
+ elsif filters.length > 1
95
+ {"$and": filters}
96
+ else
97
+ {}
98
+ end
99
+
100
+ list(**opts.merge(params: {filter: filter}))
101
+ end
102
+
103
+ # # Find deals closing soon (requires close_date attribute)
104
+ # # @param days [Integer] Number of days from today
105
+ # # @return [Attio::ListObject] List of deals closing soon
106
+ # def closing_soon(days: 30, **opts)
107
+ # today = Date.today
108
+ # end_date = today + days
109
+ #
110
+ # list(**opts.merge(params: {
111
+ # filter: {
112
+ # "$and": [
113
+ # {close_date: {"$gte": today.iso8601}},
114
+ # {close_date: {"$lte": end_date.iso8601}},
115
+ # {stage: {"$ne": "Won 🎉"}},
116
+ # {stage: {"$ne": "Lost"}}
117
+ # ]
118
+ # }
119
+ # }))
120
+ # end
121
+
122
+ # Find deals by owner
123
+ # @param owner_id [String] The workspace member ID
124
+ # @return [Attio::ListObject] List of deals owned by the member
125
+ def find_by_owner(owner_id, **opts)
126
+ list(**opts.merge(params: {
127
+ filter: {
128
+ owner: {
129
+ target_object: "workspace_members",
130
+ target_record_id: owner_id
131
+ }
132
+ }
133
+ }))
134
+ end
135
+
136
+ private
137
+
138
+ # Build filter for status field (maps to stage)
139
+ def filter_by_status(value)
140
+ { stage: value }
141
+ end
142
+ end
143
+
144
+ # Get the deal name
145
+ # @return [String, nil] The deal name
146
+ def name
147
+ self[:name]
148
+ end
149
+
150
+ # Get the deal value
151
+ # @return [Numeric, nil] The deal value
152
+ def value
153
+ self[:value]
154
+ end
155
+
156
+ # Get the deal stage (API uses "stage" but we provide status for compatibility)
157
+ # @return [String, nil] The deal stage
158
+ def stage
159
+ self[:stage]
160
+ end
161
+
162
+ # Alias for stage (for compatibility)
163
+ # @return [String, nil] The deal stage
164
+ def status
165
+ self[:stage]
166
+ end
167
+
168
+ # # Get the close date (if attribute exists)
169
+ # # @return [String, nil] The close date
170
+ # def close_date
171
+ # self[:close_date]
172
+ # end
173
+
174
+ # # Get the probability (if attribute exists)
175
+ # # @return [Numeric, nil] The win probability
176
+ # def probability
177
+ # self[:probability]
178
+ # end
179
+
180
+ # Get the owner reference
181
+ # @return [Hash, nil] The owner reference
182
+ def owner
183
+ self[:owner]
184
+ end
185
+
186
+ # Get the company reference
187
+ # @return [Hash, nil] The company reference
188
+ def company
189
+ self[:company]
190
+ end
191
+
192
+ # Update the deal stage
193
+ # @param new_stage [String] The new stage
194
+ # @return [Attio::Deal] The updated deal
195
+ def update_stage(new_stage, **opts)
196
+ self.class.update(id, values: {stage: new_stage}, **opts)
197
+ end
198
+
199
+ # Alias for update_stage (for compatibility)
200
+ # @param new_status [String] The new status/stage
201
+ # @return [Attio::Deal] The updated deal
202
+ def update_status(new_status, **opts)
203
+ update_stage(new_status, **opts)
204
+ end
205
+
206
+ # # Update the deal probability (if attribute exists)
207
+ # # @param new_probability [Numeric] The new probability (0-100)
208
+ # # @return [Attio::Deal] The updated deal
209
+ # def update_probability(new_probability, **opts)
210
+ # self.class.update(id, values: {probability: new_probability}, **opts)
211
+ # end
212
+
213
+ # Update the deal value
214
+ # @param new_value [Numeric] The new value
215
+ # @return [Attio::Deal] The updated deal
216
+ def update_value(new_value, **opts)
217
+ self.class.update(id, values: {value: new_value}, **opts)
218
+ end
219
+
220
+ # Get the associated company record
221
+ # @return [Attio::Company, nil] The company record if associated
222
+ def company_record(**opts)
223
+ return nil unless company
224
+
225
+ company_id = company.is_a?(Hash) ? company["target_record_id"] : company
226
+ Company.retrieve(company_id, **opts) if company_id
227
+ end
228
+
229
+ # Get the owner workspace member record
230
+ # @return [Attio::WorkspaceMember, nil] The owner record if assigned
231
+ def owner_record(**opts)
232
+ return nil unless owner
233
+
234
+ owner_id = if owner.is_a?(Hash)
235
+ owner["referenced_actor_id"] || owner["target_record_id"]
236
+ else
237
+ owner
238
+ end
239
+ WorkspaceMember.retrieve(owner_id, **opts) if owner_id
240
+ end
241
+
242
+ # # Calculate expected revenue (value * probability / 100)
243
+ # # @return [Numeric, nil] The expected revenue
244
+ # def expected_revenue
245
+ # return nil unless value && probability
246
+ # (value * probability / 100.0).round(2)
247
+ # end
248
+
249
+ # Check if the deal is open
250
+ # @return [Boolean] True if the deal is open
251
+ def open?
252
+ return false unless stage
253
+
254
+ stage_title = stage.is_a?(Hash) ? stage.dig("status", "title") : stage
255
+ stage_title && !["won 🎉", "lost"].include?(stage_title.downcase)
256
+ end
257
+
258
+ # Check if the deal is won
259
+ # @return [Boolean] True if the deal is won
260
+ def won?
261
+ return false unless stage
262
+
263
+ stage_title = stage.is_a?(Hash) ? stage.dig("status", "title") : stage
264
+ stage_title && stage_title.downcase.include?("won")
265
+ end
266
+
267
+ # Check if the deal is lost
268
+ # @return [Boolean] True if the deal is lost
269
+ def lost?
270
+ return false unless stage
271
+
272
+ stage_title = stage.is_a?(Hash) ? stage.dig("status", "title") : stage
273
+ stage_title && stage_title.downcase == "lost"
274
+ end
275
+
276
+ # # Check if the deal is overdue
277
+ # # @return [Boolean] True if close date has passed and deal is still open
278
+ # def overdue?
279
+ # return false unless close_date && open?
280
+ # Date.parse(close_date) < Date.today
281
+ # end
282
+ end
283
+
284
+ # Alias for Deal (plural form)
285
+ # @example
286
+ # Attio::Deals.create(name: "New Deal", value: 10000)
287
+ Deals = Deal
288
+ 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,10 +103,23 @@ 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
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.1"
6
6
  end
data/lib/attio.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "attio/resources/object"
11
11
  require_relative "attio/resources/typed_record"
12
12
  require_relative "attio/resources/person"
13
13
  require_relative "attio/resources/company"
14
+ require_relative "attio/resources/deal"
14
15
  require_relative "attio/resources/attribute"
15
16
  require_relative "attio/resources/list"
16
17
  require_relative "attio/resources/webhook"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: attio-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Beene
@@ -335,9 +335,10 @@ files:
335
335
  - LICENSE
336
336
  - README.md
337
337
  - Rakefile
338
- - attio-ruby.gemspec
339
338
  - docs/CODECOV_SETUP.md
339
+ - examples/app_specific_typed_record.md
340
340
  - examples/basic_usage.rb
341
+ - examples/deals.rb
341
342
  - examples/oauth_flow.rb
342
343
  - examples/oauth_flow_README.md
343
344
  - examples/typed_records_example.rb
@@ -354,6 +355,7 @@ files:
354
355
  - lib/attio/resources/attribute.rb
355
356
  - lib/attio/resources/comment.rb
356
357
  - lib/attio/resources/company.rb
358
+ - lib/attio/resources/deal.rb
357
359
  - lib/attio/resources/entry.rb
358
360
  - lib/attio/resources/list.rb
359
361
  - lib/attio/resources/meta.rb
data/attio-ruby.gemspec DELETED
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/attio/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "attio-ruby"
7
- spec.version = Attio::VERSION
8
- spec.authors = ["Robert Beene"]
9
- spec.email = ["robert@ismly.com"]
10
-
11
- spec.summary = "Ruby client library for the Attio API"
12
- spec.description = "A comprehensive Ruby client library for the Attio CRM API with OAuth support, type safety, and extensive test coverage"
13
- spec.homepage = "https://github.com/rbeene/attio_ruby"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.4.0"
16
-
17
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
- spec.metadata["homepage_uri"] = spec.homepage
19
- spec.metadata["source_code_uri"] = "https://github.com/rbeene/attio_ruby"
20
- spec.metadata["changelog_uri"] = "https://github.com/rbeene/attio_ruby/blob/main/CHANGELOG.md"
21
- spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/attio-ruby"
22
- spec.metadata["bug_tracker_uri"] = "https://github.com/rbeene/attio_ruby/issues"
23
-
24
- # Specify which files should be added to the gem when it is released.
25
- spec.files = Dir.chdir(__dir__) do
26
- `git ls-files -z`.split("\x0").reject do |f|
27
- (File.expand_path(f) == __FILE__) ||
28
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
29
- end
30
- end
31
- spec.bindir = "exe"
32
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
- spec.require_paths = ["lib"]
34
-
35
- # Runtime dependencies
36
- spec.add_dependency "faraday", "~> 2.0"
37
- spec.add_dependency "faraday-retry", "~> 2.0"
38
- spec.add_dependency "ostruct", "~> 0.6"
39
-
40
- # Development dependencies
41
- spec.add_development_dependency "bundler", "~> 2.0"
42
- spec.add_development_dependency "rake", "~> 13.0"
43
- spec.add_development_dependency "rspec", "~> 3.12"
44
- spec.add_development_dependency "webmock", "~> 3.18"
45
- spec.add_development_dependency "simplecov", "~> 0.22"
46
- spec.add_development_dependency "simplecov-cobertura", "~> 2.1"
47
- spec.add_development_dependency "yard", "~> 0.9"
48
- spec.add_development_dependency "redcarpet", "~> 3.6"
49
- spec.add_development_dependency "rubocop", "~> 1.50"
50
- spec.add_development_dependency "rubocop-rspec", "~> 2.20"
51
- spec.add_development_dependency "rubocop-performance", "~> 1.17"
52
- spec.add_development_dependency "standard", "~> 1.28"
53
- spec.add_development_dependency "benchmark-ips", "~> 2.12"
54
- spec.add_development_dependency "pry", "~> 0.14"
55
- spec.add_development_dependency "pry-byebug", "~> 3.10"
56
- spec.add_development_dependency "dotenv", "~> 2.8"
57
- spec.add_development_dependency "timecop", "~> 0.9"
58
- spec.add_development_dependency "bundle-audit", "~> 0.1"
59
- spec.add_development_dependency "brakeman", "~> 6.0"
60
- spec.metadata["rubygems_mfa_required"] = "true"
61
- end