halfpipe 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dc40923fa2235403dd30cf796bb0d7d807507b401e39c3a079af2be4f1f83a3d
4
+ data.tar.gz: 7c790b80900bae25d6dc295046ad08f491e5c977a85dbd999e06d753f01350ea
5
+ SHA512:
6
+ metadata.gz: 9dc32cd78f4781b1b875476fa5602e34cd3ff26a8b46dba908bfc7de4578f801586547eea4d978fe7a2e6a2d3d0d9ef8b6e2fc872d79005620c11de4aedb4880
7
+ data.tar.gz: 1b1e23c59b58011e6ca7fedced7373138ebb98a63e7ea50cefd3a301f7ef894f72bc8734308fff40e924c0876620c477c06260cbf34b528d19b16c43521e64bd
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
3
+ ruby_version: 3.0
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## [0.0.1] - 2022-01-13
2
+
3
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "rake"
6
+ gem "minitest"
7
+ gem "standard"
8
+ gem "dotenv"
9
+ gem "pry"
data/Gemfile.lock ADDED
@@ -0,0 +1,56 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ halfpipe (0.0.1)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.2)
10
+ coderay (1.1.3)
11
+ dotenv (2.7.6)
12
+ method_source (1.0.0)
13
+ minitest (5.15.0)
14
+ parallel (1.21.0)
15
+ parser (3.1.0.0)
16
+ ast (~> 2.4.1)
17
+ pry (0.14.1)
18
+ coderay (~> 1.1)
19
+ method_source (~> 1.0)
20
+ rainbow (3.1.1)
21
+ rake (13.0.6)
22
+ regexp_parser (2.2.0)
23
+ rexml (3.2.5)
24
+ rubocop (1.24.1)
25
+ parallel (~> 1.10)
26
+ parser (>= 3.0.0.0)
27
+ rainbow (>= 2.2.2, < 4.0)
28
+ regexp_parser (>= 1.8, < 3.0)
29
+ rexml
30
+ rubocop-ast (>= 1.15.1, < 2.0)
31
+ ruby-progressbar (~> 1.7)
32
+ unicode-display_width (>= 1.4.0, < 3.0)
33
+ rubocop-ast (1.15.1)
34
+ parser (>= 3.0.1.1)
35
+ rubocop-performance (1.13.1)
36
+ rubocop (>= 1.7.0, < 2.0)
37
+ rubocop-ast (>= 0.4.0)
38
+ ruby-progressbar (1.11.0)
39
+ standard (1.6.0)
40
+ rubocop (= 1.24.1)
41
+ rubocop-performance (= 1.13.1)
42
+ unicode-display_width (2.1.0)
43
+
44
+ PLATFORMS
45
+ arm64-darwin-21
46
+
47
+ DEPENDENCIES
48
+ dotenv
49
+ halfpipe!
50
+ minitest
51
+ pry
52
+ rake
53
+ standard
54
+
55
+ BUNDLED WITH
56
+ 2.3.3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Test Double, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,304 @@
1
+ <img src="https://user-images.githubusercontent.com/79303/149438778-ab0e7337-cf3a-4db8-8c03-0f04ccd83179.jpg" width="90%"/>
2
+
3
+ # Halfpipe - a Pipedrive client that doesn't do half of what you want
4
+
5
+ If you're scouring [RubyGems.org](https://rubygems.org) for a general-purpose
6
+ client to the [Pipedrive](https://www.pipedrive.com)
7
+ [API](https://developers.pipedrive.com/docs/api/v1), this is probably not the
8
+ gem for you ([here's why](#why-doesnt-this-gem-do-what-i-want)). Here's what it
9
+ does and how to use it anyway.
10
+
11
+ Halfpipe's API is split between compound actions (read: features we needed
12
+ ourselves) and an assortment of wrapped HTTP endpoints (aka stuff we needed in
13
+ order to implement those actions). Most methods return
14
+ [Structs](https://ruby-doc.org/core-3.0.0/Struct.html) encapsulating the minimum
15
+ number of attributes needed to accomplish these actions.
16
+
17
+ If Halfpipe doesn't do what you're looking for, you might consider just using
18
+ its [Http](#halfpipehttp) methods, since they at least handle HTTP request
19
+ authentication, pagination, and rate limiting for you.
20
+
21
+ # Setup
22
+
23
+ Add this to your Gemfile and kickflip it:
24
+
25
+ ```ruby
26
+ gem "halfpipe"
27
+ ```
28
+
29
+ For configuration, the gem needs your Pipedrive subdomain and [API
30
+ Token](https://pipedrive.readme.io/docs/how-to-find-the-api-token). If your
31
+ subdomain is `"alwaysbeselling"` and your API token is stored in an environment
32
+ variable named `PIPEDRIVE_API_TOKEN`, you can configure Halfpipe like this:
33
+
34
+ ```ruby
35
+ Halfpipe.config(
36
+ subdomain: "alwaysbeselling"
37
+ api_token: ENV["PIPEDRIVE_API_TOKEN"]
38
+ )
39
+ ```
40
+
41
+ # Primary API
42
+
43
+ ## Halfpipe.create_deal_for_person
44
+
45
+ Test Double [has a contact form](https://testdouble.com/contact) that takes a
46
+ handful of inputs and uses it to create a deal in Pipedrive so we can stay
47
+ organized. To do this seemingly simple thing requires numerous interactions with
48
+ the Pipedrive API: find-or-create the
49
+ [person](https://developers.pipedrive.com/docs/api/v1/Persons), find the first
50
+ [stage](https://developers.pipedrive.com/docs/api/v1/Stages) of the intended
51
+ [pipeline](https://developers.pipedrive.com/docs/api/v1/Pipelines), find any
52
+ custom [deal fields](https://developers.pipedrive.com/docs/api/v1/DealFields)
53
+ that we want to set, create the
54
+ [deal](https://developers.pipedrive.com/docs/api/v1/Deals), and then (finally!)
55
+ attach a [note](https://developers.pipedrive.com/docs/api/v1/Notes) to the deal.
56
+
57
+ To accomplish this, here's what Halfpipe offers:
58
+
59
+ ```ruby
60
+ Halfpipe.create_deal_for_person(
61
+ name: "Person Face",
62
+ email: "person.face@example.com",
63
+ deal_title: "Person Face lead via Halfpipe",
64
+ custom_deal_fields: {
65
+ "How they heard about us" => "A GitHub README",
66
+ "Inbound CTA" => "halfpipe-github-readme"
67
+ },
68
+ note_content: "Greetings!",
69
+ pipeline_name: "Halfpipe Leads"
70
+ )
71
+ ```
72
+
73
+ **[Heads up:** any `custom_deal_fields` you send need to be an exact textual
74
+ match for a [field](https://support.pipedrive.com/en/article/custom-fields)
75
+ configured in your Pipedrive instance or, failing that, boil down to the same
76
+ string when stripped of extraneous whitespace and punctuation (e.g.
77
+ `how_they_heard_about_us` and `inbound_cta` above).**]**
78
+
79
+ # Supporting API
80
+
81
+ Halfpipe is unapologetically *not* a complete wrapper for Pipedrive's API
82
+ and instead only provides methods that we had to write in support of this gem's
83
+ [Primary API](#primary-api), so YMMV (but this is open source, so it's already
84
+ YMMV).
85
+
86
+ ## Halfpipe::Api::Persons
87
+
88
+ ### Halfpipe::Api::Persons.find_by_email(email)
89
+
90
+ This method will return the first person in Pipedrive with an e-mail that's an
91
+ exact match for what you provide:
92
+
93
+ ```ruby
94
+ > person = Halfpipe::Api::Persons.find_by_email("person.face@example.com")
95
+ => #<struct Halfpipe::Person
96
+ id=9,
97
+ name="Person Face",
98
+ email="person.face@example.com",
99
+ organization_id=2>
100
+ ```
101
+
102
+ ### Halfpipe::Api::Persons.create(name:, email:)
103
+
104
+ This method will create a new person with the provided name & e-mail address,
105
+ returning a `Struct` that includes the resulting ID:
106
+
107
+ ```ruby
108
+ > person = Halfpipe::Api::Persons.create(
109
+ name: "A person",
110
+ email: "person.face@example.com"
111
+ )
112
+ => #<struct Halfpipe::Person
113
+ id=10,
114
+ name="A person",
115
+ email="person.face@example.com",
116
+ organization_id=nil>
117
+ ```
118
+
119
+ ## Halfpipe::Api::Deals
120
+
121
+ ### Halfpipe::Api::Deals.create(title:, stage_id: nil, person_id: nil, organization_id: nil, custom_fields: {})
122
+
123
+ This method creates a new deal with the properties you pass it. Only `title` and
124
+ either `person_id` or `organization_id` is required:
125
+
126
+ ```ruby
127
+ > deal = Halfpipe::Api::Deals.create(title: "A deal!", organization_id: 1)
128
+ => #<struct Halfpipe::Deal
129
+ id=31,
130
+ title="A deal!",
131
+ stage_id=1,
132
+ person_id=nil,
133
+ organization_id=1>
134
+ ```
135
+
136
+ ## Halfpipe::Api::DealFields
137
+
138
+ ### Halfpipe::Api::DealFields.get
139
+
140
+ This method retrieves all the custom fields you've defined for deals in your
141
+ Pipedrive instance. Why was this necessary for the gem? We need to fetch
142
+ these `DealField` entities and map the string name of any custom fields to the
143
+ hash key assigned by Pipedrive.
144
+
145
+ ```ruby
146
+ > deal_fields = Halfpipe::Api::DealFields.get
147
+ => [#<struct Halfpipe::DealField key="title", name="Title", symbol="title">,
148
+ #<struct Halfpipe::DealField key="creator_user_id", name="Creator", symbol="creator">,
149
+ #<struct Halfpipe::DealField key="user_id", name="Owner", symbol="owner">,
150
+ #<struct Halfpipe::DealField key="value", name="Value", symbol="value">,
151
+ #… etc…
152
+ ]
153
+ ```
154
+
155
+ ## Halfpipe::Api::Stages
156
+
157
+ ### Halfpipe::Api::Stages.find_first_stage_by_pipeline_name(pipeline_name)
158
+
159
+ This method will return the first stage in the first Pipedrive with the given
160
+ `pipeline_name`.
161
+
162
+ ```ruby
163
+ > stage = Halfpipe::Api::Stages.find_first_stage_by_pipeline_name("Pipeline")
164
+ => #<struct Halfpipe::Stage
165
+ id=1,
166
+ pipeline_id=1,
167
+ name="Qualified">
168
+ ```
169
+
170
+ ## Halfpipe::Api::Notes
171
+
172
+ ### Halfpipe::Api::Notes.create(content:, deal_id: nil, person_id: nil, organization_id: nil)
173
+
174
+ This method creates notes and attaches them to the provided deal, person, and/or
175
+ organization (at least one is required).
176
+
177
+ ```ruby
178
+ > note = Halfpipe::Api::Notes.create(person_id: 1, content: "Greetings!")
179
+ => #<struct Halfpipe::Note
180
+ id=11,
181
+ content="Greetings!",
182
+ deal_id=nil,
183
+ person_id=1,
184
+ organization_id=nil>
185
+ ```
186
+
187
+ ## Halfpipe::Http
188
+
189
+ ### Halfpipe::Http.get(path, params: {}, start: 0)
190
+
191
+ This method will send a `GET` request on your behalf, appending the required
192
+ `api_token` query parameter along with whatever other params you send.
193
+ Additionally, it will paginate throughout _all_ the results that your query
194
+ might return. Until the job is done, it'll even wait whatever retry amount the
195
+ API's `x-ratelimit-reset` header tells it to wait!
196
+
197
+ You can use this method to fetch stuff that isn't supported by the rest of the
198
+ API. You'll just get hashes back instead of custom `Struct` instances:
199
+
200
+ ```ruby
201
+ > pipelines = Halfpipe::Http.get("/pipelines")
202
+ => [{"id"=>1,
203
+ "name"=>"Pipeline",
204
+ "url_title"=>"default",
205
+ "order_nr"=>1,
206
+ "active"=>true,
207
+ "deal_probability"=>false,
208
+ "add_time"=>"2022-01-04 12:35:34",
209
+ "update_time"=>nil,
210
+ "selected"=>true},
211
+ {"id"=>2,
212
+ "name"=>"Test Double Leads",
213
+ "url_title"=>"Test-Double-Leads",
214
+ "order_nr"=>2,
215
+ "active"=>true,
216
+ "deal_probability"=>true,
217
+ "add_time"=>"2022-01-07 16:14:08",
218
+ "update_time"=>"2022-01-07 16:14:08",
219
+ "selected"=>false}]
220
+ ```
221
+
222
+ ### Halfpipe::Http.post(path, params: {})
223
+
224
+ This method is a straightforward wrapper of
225
+ [Net::HTTP.post_form](https://ruby-doc.org/stdlib-3.0.0/libdoc/net/http/rdoc/Net/HTTP.html#method-c-post_form):
226
+
227
+ ```ruby
228
+ > organization = Halfpipe::Http.post("/organizations", params: {name: "An org"})
229
+ => {"id"=>3,
230
+ "company_id"=>10804948,
231
+ "owner_id"=>
232
+ {"id"=>853119,
233
+ "name"=>"Justin Searls",
234
+ # etc.
235
+ },
236
+ "name"=>"An org",
237
+ "open_deals_count"=>0,
238
+ "related_open_deals_count"=>0,
239
+ "closed_deals_count"=>0,
240
+ # etc.
241
+ }
242
+ ```
243
+
244
+ ### Halfpipe::Http.delete(path, params: {})
245
+
246
+ Fair warning: the gem only uses this method to clean up test data:
247
+
248
+ ```ruby
249
+ > Halfpipe::Http.delete("/notes/#{id}")
250
+ => # The Net::Http::Response
251
+ ```
252
+
253
+ ## Why doesn't this gem do what I want?
254
+
255
+ Pipedrive—and
256
+ [CRM](https://en.wikipedia.org/wiki/Customer_relationship_management) tools
257
+ generally—are devilishly simple. At their most basic, they only require a
258
+ half-dozen models ("Person", "Lead", "Deal", etc.) and their core functionality
259
+ can easily be expressed with familiar CRUD actions ("create a new deal", "change
260
+ the status of this deal")… what's so hard about that?
261
+
262
+ Here's the tricky part: lying just beneath the surface of every CRM tool, there
263
+ is an entire [key-value
264
+ database](https://en.wikipedia.org/wiki/Key–value_database), and the users
265
+ (usually salespeople) are its
266
+ [DBAs](https://en.wikipedia.org/wiki/Database_administrator). Each of a CRM's
267
+ seemingly-simple models might have infinitely many custom fields defined by the
268
+ user—sometimes more than one with the same name! Each field can be one of any
269
+ number of types, including compound types composed of other fields! And, of
270
+ course, each record can be linked to one or more of any other type of record,
271
+ and there's nothing to stop a custom field from defining additional
272
+ associations!
273
+
274
+ As if that weren't enough, a CRM needs to serve as an authoritative source of
275
+ record for a company's prospects, customers, and contacts despite the fact that
276
+ its interactions with those people occur in countless other systems far beyond
277
+ the CRM tool's purview. As a result, most CRMs optimize for two user experiences
278
+ that are relevant to think about for anyone hoping to integrate with them:
279
+
280
+ 1. Low-friction data ingestion across numerous points of ingress: phone, e-mail,
281
+ web, newsletter, ad networks, partner sites, etc.
282
+ 2. Sophisticated sanitization, de-duplication, and merging of the data chaos
283
+ that inevitably results from Step 1
284
+
285
+ So, if you're building a general-purpose API client or an application that
286
+ aspires to provide a comprehensive view of a CRM's data, you need to prepare for
287
+ every eventuality. To recap: every record has infinitely many nested attributes
288
+ with names you can't easily know (and which may not be unique), each field
289
+ having types defined by the user, and which could be associated with every other
290
+ record multiple times over. And you have to be careful how you store things,
291
+ since yesterday's ID for any given could be an entirely different ID tomorrow if
292
+ someone clicked "Merge" in a way you didn't expect.
293
+
294
+ And that's why this gem doesn't get fancy. It just provides a handful of actions
295
+ we find useful against Pipedrive CRM and then bails out. 🛹
296
+
297
+ ## Code of Conduct
298
+
299
+ This project follows Test Double's [code of
300
+ conduct](https://testdouble.com/code-of-conduct) for all community interactions,
301
+ including (but not limited to) one-on-one communications, public posts/comments,
302
+ code reviews, pull requests, and GitHub issues. If violations occur, Test Double
303
+ will take any action they deem appropriate for the infraction, up to and
304
+ including blocking a user from the organization's repositories.
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ require "standard/rake"
13
+
14
+ task default: %i[test standard:fix]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "dotenv/load"
6
+ require "halfpipe"
7
+
8
+ Halfpipe.config(
9
+ api_token: ENV["PIPEDRIVE_API_KEY"],
10
+ subdomain: ENV["PIPEDRIVE_SUBDOMAIN"]
11
+ )
12
+
13
+ # (If you use this, don't forget to add pry to your Gemfile!)
14
+ require "pry"
15
+ Pry.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/halfpipe.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ require_relative "lib/halfpipe/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "halfpipe"
5
+ spec.version = Halfpipe::VERSION
6
+ spec.authors = ["Justin Searls"]
7
+ spec.email = ["searls@gmail.com"]
8
+
9
+ spec.summary = "A Pipedrive client that doesn't do half of what you want"
10
+ spec.homepage = "https://github.com/testdouble/halfpipe"
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = ">= 3.0.0"
13
+
14
+ spec.metadata["homepage_uri"] = spec.homepage
15
+ spec.metadata["source_code_uri"] = spec.homepage
16
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject do |f|
22
+ (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
23
+ end
24
+ end
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+ end
@@ -0,0 +1,15 @@
1
+ module Halfpipe
2
+ module Api
3
+ module DealFields
4
+ def self.get
5
+ Http.get("/dealFields").map { |json|
6
+ DealField.new(
7
+ key: json["key"],
8
+ name: json["name"],
9
+ symbol: Halfpipe::Strings.deform(json["name"])
10
+ )
11
+ }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,45 @@
1
+ require_relative "deal_fields"
2
+
3
+ module Halfpipe
4
+ module Api
5
+ module Deals
6
+ def self.create(title:, stage_id: nil, person_id: nil, organization_id: nil, custom_fields: {})
7
+ pipedrive_keyed_custom_fields = map_custom_fields(custom_fields)
8
+ json = Http.post("/deals", params: {
9
+ title: title,
10
+ stage_id: stage_id,
11
+ person_id: person_id,
12
+ org_id: organization_id
13
+ }.merge(pipedrive_keyed_custom_fields).compact)
14
+
15
+ Deal.new(
16
+ id: json["id"],
17
+ stage_id: json["stage_id"],
18
+ person_id: json.dig("person_id", "value"),
19
+ organization_id: json.dig("org_id", "value"),
20
+ title: title
21
+ )
22
+ end
23
+ class << self
24
+ private
25
+
26
+ def map_custom_fields(custom_fields)
27
+ pipedrive_fields = DealFields.get
28
+ custom_fields.map { |name, value|
29
+ pipedrive_field = pipedrive_fields.find { |pipedrive_field|
30
+ pipedrive_field.name == name
31
+ } || pipedrive_fields.find { |pipedrive_field|
32
+ pipedrive_field.name == Halfpipe::Strings.deform(name)
33
+ }
34
+ if pipedrive_field.nil?
35
+ raise Error.new <<~MSG
36
+ Failed to find custom deal field in Pipedrive named #{name.inspect}
37
+ MSG
38
+ end
39
+ [pipedrive_field.key, value]
40
+ }.to_h
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,21 @@
1
+ module Halfpipe
2
+ module Api
3
+ module Notes
4
+ def self.create(content:, deal_id: nil, person_id: nil, organization_id: nil)
5
+ json = Http.post("/notes", params: {
6
+ content: content,
7
+ deal_id: deal_id,
8
+ person_id: person_id,
9
+ org_id: organization_id
10
+ })
11
+ Note.new(
12
+ id: json["id"],
13
+ content: content,
14
+ deal_id: deal_id,
15
+ person_id: person_id,
16
+ organization_id: organization_id
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,35 @@
1
+ module Halfpipe
2
+ module Api
3
+ module Persons
4
+ def self.find_by_email(email)
5
+ json = Http.get("/persons/search", params: {
6
+ term: email,
7
+ fields: "email",
8
+ exact_match: true
9
+ }).first&.fetch("item")
10
+
11
+ unless json.nil?
12
+ Person.new(
13
+ id: json["id"],
14
+ name: json["name"],
15
+ email: email,
16
+ organization_id: json.dig("organization", "id")
17
+ )
18
+ end
19
+ end
20
+
21
+ def self.create(name:, email:)
22
+ json = Http.post("/persons", params: {
23
+ name: name,
24
+ email: email
25
+ })
26
+
27
+ Person.new(
28
+ id: json["id"],
29
+ name: json["name"],
30
+ email: email
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ module Halfpipe
2
+ module Api
3
+ module Stages
4
+ def self.find_first_stage_by_pipeline_name(pipeline_name)
5
+ json = Http.get("/stages").select { |json|
6
+ json["pipeline_name"] == pipeline_name
7
+ }.min_by { |json| json["order_nr"] }
8
+
9
+ unless json.nil?
10
+ Stage.new(
11
+ id: json["id"],
12
+ pipeline_id: json["pipeline_id"],
13
+ name: json["name"]
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module Halfpipe
2
+ class Config
3
+ attr_accessor :api_token, :subdomain, :debug
4
+
5
+ def initialize
6
+ @debug = false
7
+ end
8
+
9
+ def set(**attrs)
10
+ @api_token = attrs[:api_token] if attrs.key?(:api_token)
11
+ @subdomain = attrs[:subdomain] if attrs.key?(:subdomain)
12
+ @debug = attrs[:debug] if attrs.key?(:debug)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,50 @@
1
+ require_relative "api/persons"
2
+ require_relative "api/stages"
3
+ require_relative "api/deals"
4
+ require_relative "api/notes"
5
+
6
+ module Halfpipe
7
+ class CreatesDealForPerson
8
+ Result = Struct.new(:stage, :person, :deal, :note, keyword_init: true)
9
+
10
+ def call(
11
+ name:, email:, deal_title:,
12
+ custom_deal_fields:, note_content:, pipeline_name:
13
+ )
14
+ stage = Api::Stages.find_first_stage_by_pipeline_name(pipeline_name)
15
+ person = find_or_create_person(name: name, email: email)
16
+ deal = Api::Deals.create(
17
+ stage_id: stage.id,
18
+ person_id: person.id,
19
+ organization_id: person.organization_id,
20
+ title: deal_title,
21
+ custom_fields: custom_deal_fields
22
+ )
23
+ unless note_content.nil?
24
+ note = Api::Notes.create(
25
+ content: note_content,
26
+ deal_id: deal.id,
27
+ person_id: person.id,
28
+ organization_id: person.organization_id
29
+ )
30
+ end
31
+
32
+ Result.new(
33
+ stage: stage,
34
+ person: person,
35
+ deal: deal,
36
+ note: note
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def find_or_create_person(name:, email:)
43
+ Api::Persons.find_by_email(email) ||
44
+ Api::Persons.create(
45
+ name: name,
46
+ email: email
47
+ )
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,97 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Halfpipe
5
+ module Http
6
+ def self.get(path, params: {}, start: 0)
7
+ uri = URI(url_for(path))
8
+ uri.query = URI.encode_www_form(params.merge(
9
+ api_token: Halfpipe.config.api_token,
10
+ start: start
11
+ ))
12
+ Log.debug("GETting #{uri}")
13
+ res = Net::HTTP.get_response(uri)
14
+ json = parse_json(res)
15
+
16
+ if rate_limited?(res)
17
+ wait_time = res["x-ratelimit-reset"].to_i
18
+ puts "Reached rate limit, sleeping #{wait_time}"
19
+ sleep wait_time
20
+ get(path, params: params, start: start)
21
+ else
22
+ raise_failure_maybe!(res, json)
23
+
24
+ # Search results are not on data, but data.items
25
+ results = if !json["data"].respond_to?(:key?) || !json["data"]&.key?("items")
26
+ json["data"]
27
+ else
28
+ json.dig("data", "items")
29
+ end
30
+
31
+ if json.dig("additional_data", "pagination", "more_items_in_collection")
32
+ results += get(
33
+ path,
34
+ params: params,
35
+ start: json.dig("additional_data", "pagination", "next_start")
36
+ )
37
+ end
38
+ results
39
+ end
40
+ end
41
+
42
+ def self.post(path, params: {})
43
+ uri = URI("#{url_for(path)}?api_token=#{Halfpipe.config.api_token}")
44
+ Log.debug("POSTing #{uri} with: #{params.inspect}")
45
+ res = Net::HTTP.post_form(uri, params)
46
+ json = parse_json(res)
47
+ raise_failure_maybe!(res, json)
48
+ json["data"]
49
+ end
50
+
51
+ def self.delete(path, params: {})
52
+ http = Net::HTTP.new("#{Halfpipe.config.subdomain}.pipedrive.com", 443)
53
+ http.use_ssl = true
54
+ query = URI.encode_www_form(params.merge(
55
+ api_token: Halfpipe.config.api_token
56
+ ))
57
+ path = "/api/v1#{path}?#{query}"
58
+ Log.debug("DELETEing #{path}")
59
+ res = http.delete(path)
60
+ unless res.is_a?(Net::HTTPSuccess)
61
+ raise Error.new <<~MSG
62
+ Deletion of #{path.inspect} failed with #{res.code}:
63
+
64
+ #{res.body}
65
+ MSG
66
+ end
67
+ end
68
+
69
+ class << self
70
+ private
71
+
72
+ def url_for(path)
73
+ "https://#{Halfpipe.config.subdomain}.pipedrive.com/api/v1#{path}"
74
+ end
75
+
76
+ def parse_json(res)
77
+ JSON.parse(res.body)
78
+ rescue
79
+ nil
80
+ end
81
+
82
+ def rate_limited?(res)
83
+ res.code == "429" || res["x-ratelimit-remaining"].to_i < 1
84
+ end
85
+
86
+ def raise_failure_maybe!(res, json)
87
+ return if res.is_a?(Net::HTTPSuccess) && json&.fetch("success")
88
+ raise Error.new <<~MSG
89
+ Pipedrive request failed with status code #{res.code}
90
+
91
+ Response body:
92
+ #{json.inspect}
93
+ MSG
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,8 @@
1
+ module Halfpipe
2
+ module Log
3
+ def self.debug(msg)
4
+ return unless Halfpipe.config.debug
5
+ puts msg
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Halfpipe
2
+ module Strings
3
+ def self.deform(s)
4
+ s.strip.downcase.gsub(/[[:punct:]]/, "").gsub(/\s+/, "_")
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Halfpipe
2
+ Person = Struct.new(:id, :name, :email, :organization_id, keyword_init: true)
3
+ Stage = Struct.new(:id, :pipeline_id, :name, keyword_init: true)
4
+ Deal = Struct.new(:id, :title, :stage_id, :person_id, :organization_id, keyword_init: true)
5
+ DealField = Struct.new(:key, :name, :symbol, keyword_init: true)
6
+ Note = Struct.new(:id, :content, :deal_id, :person_id, :organization_id, keyword_init: true)
7
+ end
@@ -0,0 +1,3 @@
1
+ module Halfpipe
2
+ VERSION = "0.0.1"
3
+ end
data/lib/halfpipe.rb ADDED
@@ -0,0 +1,31 @@
1
+ require_relative "halfpipe/version"
2
+ require_relative "halfpipe/strings"
3
+ require_relative "halfpipe/config"
4
+ require_relative "halfpipe/log"
5
+ require_relative "halfpipe/http"
6
+ require_relative "halfpipe/values"
7
+ require_relative "halfpipe/creates_deal_for_person"
8
+
9
+ module Halfpipe
10
+ class Error < StandardError; end
11
+
12
+ def self.create_deal_for_person(
13
+ email:, deal_title:, name: nil,
14
+ custom_deal_fields: {}, note_content: nil, pipeline_name: nil
15
+ )
16
+ CreatesDealForPerson.new.call(
17
+ name: name,
18
+ email: email,
19
+ deal_title: deal_title,
20
+ custom_deal_fields: custom_deal_fields,
21
+ note_content: note_content,
22
+ pipeline_name: pipeline_name
23
+ )
24
+ end
25
+
26
+ def self.config(**attrs)
27
+ (@config ||= Config.new).tap do |config|
28
+ config.set(**attrs)
29
+ end
30
+ end
31
+ end
data/sig/halfpipe.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Halfpipe
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: halfpipe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Justin Searls
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-01-19 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - searls@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".standard.yml"
21
+ - CHANGELOG.md
22
+ - Gemfile
23
+ - Gemfile.lock
24
+ - LICENSE.txt
25
+ - README.md
26
+ - Rakefile
27
+ - bin/console
28
+ - bin/setup
29
+ - halfpipe.gemspec
30
+ - lib/halfpipe.rb
31
+ - lib/halfpipe/api/deal_fields.rb
32
+ - lib/halfpipe/api/deals.rb
33
+ - lib/halfpipe/api/notes.rb
34
+ - lib/halfpipe/api/persons.rb
35
+ - lib/halfpipe/api/stages.rb
36
+ - lib/halfpipe/config.rb
37
+ - lib/halfpipe/creates_deal_for_person.rb
38
+ - lib/halfpipe/http.rb
39
+ - lib/halfpipe/log.rb
40
+ - lib/halfpipe/strings.rb
41
+ - lib/halfpipe/values.rb
42
+ - lib/halfpipe/version.rb
43
+ - sig/halfpipe.rbs
44
+ homepage: https://github.com/testdouble/halfpipe
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ homepage_uri: https://github.com/testdouble/halfpipe
49
+ source_code_uri: https://github.com/testdouble/halfpipe
50
+ changelog_uri: https://github.com/testdouble/halfpipe/blob/main/CHANGELOG.md
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 3.0.0
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.3.3
67
+ signing_key:
68
+ specification_version: 4
69
+ summary: A Pipedrive client that doesn't do half of what you want
70
+ test_files: []