jbr 1.2.0 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68463c13bcdbb8a69d415d79426415cab50a27d805c96132859c0301e3eab8b2
4
- data.tar.gz: f134a69e00d4cf3a6b0382701a05c81de434f5a246555d8f8955c97512806ced
3
+ metadata.gz: 6c3baeab4ed52ddaf9e86a1086b34b84da47a9bce32a46e05036145dc1c9ca86
4
+ data.tar.gz: e0c89dd53a6e64a15bba3b748b1689f2cc063030ba64ed13b18364d1768a6f99
5
5
  SHA512:
6
- metadata.gz: ba24574d4b690e6b22019414dd17c0a63ab07f5f7239667da987fc6f6b0f7c0b809ca863b64e52e62408c2f887c18c591450499d949c01cb3b805fbd29ed90eb
7
- data.tar.gz: 40bd20b0fbe0315613a2fd883ceb906f48f5e088ac53b1bc631a453c795a0bc71a0a4c0dc7f59fd8ca91611707055cb877adecfbefeceb41224204d3045d2c3f
6
+ metadata.gz: cd207ad99f2769ae55bcf2a4f5b149118bafa49f6638d18a4e4a9661ff5729cb1fd1398b67af68cfb17dd251a851b46a350835621a34ed3f888f3676e774d621
7
+ data.tar.gz: d8dc7694dff3aa8ef51480ab51500c94903cf0118f6d075e9b8ada9fae761dcb7f91f133a9eb7ee4894c33a18fab59645ae138ccd4aec367fa5c0ccbf50c0a9f
data/.rubocop.yml ADDED
@@ -0,0 +1,59 @@
1
+ # Omakase Ruby styling for Rails
2
+ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
3
+
4
+ # The omakase base disables whole departments and re-enables a chosen subset, so
5
+ # every cop it does not name needs an explicit 'Enabled: true' below.
6
+
7
+ AllCops:
8
+ # Adopt every cop a new release brings, rather than pinning to today's set
9
+ NewCops: enable
10
+ # An extension we decline is declined in this file, not merely ignored
11
+ SuggestExtensions: false
12
+
13
+ # We never freeze a string, so the magic comment has nothing to say
14
+ Style/FrozenStringLiteralComment:
15
+ Enabled: true
16
+ EnforcedStyle: never
17
+
18
+ # The cop demands '.freeze' on string constants and cannot skip them
19
+ Style/MutableConstant:
20
+ Enabled: false
21
+
22
+ Style/StringLiterals:
23
+ EnforcedStyle: single_quotes
24
+
25
+ Style/StringLiteralsInInterpolation:
26
+ Enabled: true
27
+ EnforcedStyle: single_quotes
28
+
29
+ Style/TrailingCommaInArrayLiteral:
30
+ EnforcedStyleForMultiline: consistent_comma
31
+
32
+ Style/TrailingCommaInHashLiteral:
33
+ EnforcedStyleForMultiline: consistent_comma
34
+
35
+ Layout/CaseIndentation:
36
+ EnforcedStyle: end
37
+ IndentOneStep: true
38
+
39
+ # 'private' sits at the level of 'class', so it reads as a divider
40
+ Layout/AccessModifierIndentation:
41
+ Enabled: true
42
+ EnforcedStyle: outdent
43
+
44
+ # One 'include' statement carries the list, split only when it will not fit
45
+ Style/MixinGrouping:
46
+ Enabled: true
47
+ EnforcedStyle: grouped
48
+
49
+ Layout/LineLength:
50
+ Enabled: true
51
+ Max: 100
52
+
53
+ # Omit parentheses on a call's arguments, keeping the inner ones parsing needs -- except
54
+ # where the call IS the condition, after 'if', 'unless', 'while', 'until' or in a ternary,
55
+ # which keeps them so the reader sees where the argument ends and the branch begins.
56
+ # 'omit_parentheses' has no option for that exception and an inline disable costs three
57
+ # lines per site, so the cop is declined and the rule held in review.
58
+ Style/MethodCallWithArgsParentheses:
59
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [2.0.0] - 2026-08-07
2
+
3
+ - [Fix] Require nothing but the standard library: to_query, present?, pluck,
4
+ stringify_keys and Time.current were ActiveSupport calls the gemspec never declared,
5
+ so `require 'jbr'` raised outside Rails
6
+ - [Fix] Reuse the property already on a client's file instead of adding a duplicate on
7
+ every request: the lookup read the address as `street`, while the comparison built it
8
+ as `street1`, so it never matched. The match is made on street and ZIP; city and state
9
+ are written but not matched, since Jobber holds whatever was typed
10
+ - [Fix] Client#create no longer raises when Jobber answers without clientProperties
11
+ - [Feature] Test every line, with SimpleCov failing the suite below 100% coverage
12
+ - [Change] Extract Jbr::Property from Jbr::Client
13
+ - [Change] Remove the unused Jbr::Configuration class
14
+
1
15
  ## [1.2.0] - 2026-06-09
2
16
 
3
17
  - [New] Create a Property with a Request if needed
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Jobber API Ruby client
2
2
 
3
+ A client for the Jobber GraphQL API. It needs nothing but the standard library.
4
+
3
5
  ## Available methods
4
6
 
5
7
  ### Credentials
@@ -9,6 +11,7 @@ Generate the URL for Jobber users to authorize the app:
9
11
  ```ruby
10
12
  url = Jbr.oauth_url_for redirect_uri:, state:
11
13
  url # => 'https://api.getjobber.com/api/oauth/authorize?state=...&redirect_uri=...'
14
+ ```
12
15
 
13
16
  Create credentials with a code and a redirect URI:
14
17
 
@@ -19,17 +22,17 @@ oauth = Jbr.create_oauth code:, redirect_uri:
19
22
  Initialize with existing credentials:
20
23
 
21
24
  ```ruby
22
- oauth = Jbr.oauth_for access_token:, refresh_token, expires_at:, account_id:
25
+ oauth = Jbr.oauth_for access_token:, refresh_token:, expires_at:, account_id:
23
26
  ```
24
27
 
25
28
  Access OAuth attributes:
26
29
 
27
- ```
30
+ ```ruby
28
31
  oauth.access_token # => 'eyJhbGciOiJIUzI1NiJ'
29
32
  oauth.refresh_token # => 'ea02775958c5fca28d'
30
33
  oauth.expires_at # => 2026-05-22 14:32:53
31
34
  oauth.account_id # => 'Z2lkOi8vSm9iYmV'
32
- ````
35
+ ```
33
36
 
34
37
  Revoke credentials:
35
38
 
@@ -90,8 +93,8 @@ Parse the payload of a Jobber event webhook:
90
93
  ```ruby
91
94
  event = Jbr::Event.new data: { webHookEvent: { topic: 'JOB_CREATE', appId: 'app-1',
92
95
  accountId: 'account-1', itemId: 'job-1', occurredAt: '2026-05-22T15:46:33Z' } }
93
- event.account_id = 'account-1
94
- event.item_id = 'job-1
96
+ event.account_id # => 'account-1'
97
+ event.item_id # => 'job-1'
95
98
  ```
96
99
 
97
100
  ## Available mocks
@@ -115,7 +118,7 @@ Jbr.mock.oauth_error = 'Flow rejected'
115
118
  Mock a custom redirect URL:
116
119
 
117
120
  ```ruby
118
- Jbr.mock.oauth_url_for = 'https://example.com'
121
+ Jbr.mock.oauth_url = 'https://example.com'
119
122
  ```
120
123
 
121
124
  ### Requests
@@ -22,7 +22,8 @@ module GraphQL
22
22
  raise Unauthorized, response.body if response.code == '401'
23
23
  raise Error, response.body unless response.is_a? Net::HTTPSuccess
24
24
  body = JSON.parse(response.body)
25
- raise Error, body['errors'].pluck('message').join('; ') if body['errors'].present?
25
+ errors = body['errors'] || []
26
+ raise Error, errors.map { |error| error['message'] }.join('; ') unless errors.empty?
26
27
  body.fetch('data')
27
28
  end
28
29
 
data/lib/jbr/account.rb CHANGED
@@ -1,9 +1,12 @@
1
1
  module Jbr
2
+ # The Jobber account a set of credentials belongs to.
2
3
  class Account < Resource
3
- FIND = <<~GRAPHQL.freeze
4
+ # The query that reads the account behind the current credentials.
5
+ FIND = <<~GRAPHQL
4
6
  { account { id } }
5
7
  GRAPHQL
6
8
 
9
+ # @return [String] the account ID, read once and remembered.
7
10
  def id
8
11
  @id ||= @oauth.query(FIND).dig 'account', 'id'
9
12
  end
data/lib/jbr/client.rb CHANGED
@@ -1,14 +1,17 @@
1
1
  module Jbr
2
+ # A person a Jobber user works for, and the property the work happens at.
2
3
  class Client < Resource
3
- LOOKUP = <<~GRAPHQL.freeze
4
+ # The query that finds a client by phone, with the properties already on file.
5
+ LOOKUP = <<~GRAPHQL
4
6
  query($searchTerm: String!) {
5
7
  clientPhones(first: 1, searchTerm: $searchTerm) { nodes {
6
- client { id updatedAt clientProperties { nodes { id address { street city province postalCode } }} }
8
+ client { id updatedAt clientProperties { nodes { id address { street1 city province postalCode } }} }
7
9
  } }
8
10
  }
9
11
  GRAPHQL
10
12
 
11
- CREATE = <<~GRAPHQL.freeze
13
+ # The mutation that opens a client, with a first property when an address is given.
14
+ CREATE = <<~GRAPHQL
12
15
  mutation($input: ClientCreateInput!) {
13
16
  clientCreate(input: $input) {
14
17
  client { id clientProperties(first: 1) { nodes { id } } }
@@ -17,15 +20,7 @@ module Jbr
17
20
  }
18
21
  GRAPHQL
19
22
 
20
- CREATE_PROPERTY = <<~GRAPHQL.freeze
21
- mutation propertyCreateMutation($clientId: EncodedId!, $input: PropertyCreateInput!) {
22
- propertyCreate(clientId: $clientId, input: $input) {
23
- properties { id }
24
- userErrors { message }
25
- }
26
- }
27
- GRAPHQL
28
-
23
+ # @return [String, nil] the property the work happens at.
29
24
  attr_reader :property_id
30
25
 
31
26
  # Create a client instance with the provided attributes.
@@ -39,6 +34,9 @@ module Jbr
39
34
  self.tap { @create_params = params }
40
35
  end
41
36
 
37
+ # Reach the client behind a phone number, opening one if Jobber has none.
38
+ # @param phone [String] the number to match on.
39
+ # @return [Client] itself.
42
40
  def find_or_create_by(phone:)
43
41
  find_by_phone(phone) || create
44
42
  self
@@ -54,17 +52,9 @@ module Jbr
54
52
  return unless recent
55
53
 
56
54
  @id = recent.dig 'client', 'id'
57
-
58
- properties = recent.dig('client', 'clientProperties', 'nodes') || []
59
- existing_property = properties.find do |property|
60
- extract_address_from(@create_params[:address]).stringify_keys == property['address']
61
- end
62
- @property_id = if existing_property
63
- existing_property['id']
64
- else
65
- property = @oauth.query CREATE_PROPERTY, variables: { clientId: @id, input: { properties: [ { address: extract_address_from(@create_params[:address]) }] } }
66
- (property&.dig('propertyCreate', 'properties')&.first || {})['id']
67
- end
55
+ @property_id = Property.new(oauth: @oauth).find_or_create_for client_id: @id,
56
+ address: @create_params[:address],
57
+ existing: recent.dig('client', 'clientProperties', 'nodes') || []
68
58
  true
69
59
  end
70
60
 
@@ -72,24 +62,20 @@ module Jbr
72
62
  output = @oauth.query CREATE, variables: { input: input }
73
63
  @id = output.dig 'clientCreate', 'client', 'id'
74
64
 
75
- properties = output.dig 'clientCreate', 'client', 'clientProperties', 'nodes'
65
+ properties = output.dig('clientCreate', 'client', 'clientProperties', 'nodes') || []
76
66
  @property_id = (properties.first || {})['id']
77
67
  end
78
68
 
79
69
  def input
70
+ address, email = @create_params[:address], @create_params[:email]
80
71
  { firstName: @create_params[:first_name],
81
72
  lastName: @create_params[:last_name],
82
- properties: ([{ address: extract_address_from(@create_params[:address]) }] if @create_params[:address].present?),
83
- phones: [{ number: @create_params[:phone], primary: true }],
84
- emails: ([{ address: @create_params[:email], primary: true }] if @create_params[:email].present?)
73
+ properties: ([ { address: Property.address_from(address) } ] if present?(address)),
74
+ phones: [ { number: @create_params[:phone], primary: true } ],
75
+ emails: ([ { address: email, primary: true } ] if present?(email)),
85
76
  }.compact
86
77
  end
87
78
 
88
- def extract_address_from(fields = {})
89
- {
90
- street1: fields[:street], city: fields[:city],
91
- province: fields[:state], postalCode: fields[:zip]
92
- }.compact
93
- end
79
+ def present?(value) = !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
94
80
  end
95
81
  end
data/lib/jbr/error.rb CHANGED
@@ -1,3 +1,4 @@
1
1
  module Jbr
2
+ # An error raised when Jobber refuses a request.
2
3
  Error = Class.new StandardError
3
4
  end
data/lib/jbr/event.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  module Jbr
2
+ # One webhook Jobber posted, and the signature header it arrives under.
2
3
  class Event
3
4
  # @see https://developer.getjobber.com/docs/using_jobbers_api/setting_up_webhooks
4
5
  SIGNATURE_HEADER = 'X-Jobber-Hmac-SHA256'
data/lib/jbr/invoice.rb CHANGED
@@ -1,14 +1,19 @@
1
1
  module Jbr
2
+ # A bill a Jobber user issued for finished work.
2
3
  class Invoice < Resource
3
- FIND = <<~GRAPHQL.freeze
4
+ # The query that reads one invoice, its total, its date and the job it bills.
5
+ FIND = <<~GRAPHQL
4
6
  query($id: EncodedId!) {
5
7
  invoice(id: $id) { id total invoiceStatus issuedDate
6
8
  jobs { nodes { id completedAt } } }
7
9
  }
8
10
  GRAPHQL
9
11
 
12
+ # @return [String, nil] the ID of the job billed, and the amount as Jobber writes it.
10
13
  attr_reader :job_id, :total
11
14
 
15
+ # @param id [String] the Jobber ID of the invoice.
16
+ # @return [Invoice, nil] itself, or nil when the invoice is missing or still a draft.
12
17
  def find(id)
13
18
  output = @oauth.query FIND, variables: { id: id }
14
19
  return unless invoice = output['invoice']
data/lib/jbr/job.rb CHANGED
@@ -1,13 +1,18 @@
1
1
  module Jbr
2
+ # Work a Jobber user accepted and scheduled.
2
3
  class Job < Resource
3
- FIND = <<~GRAPHQL.freeze
4
+ # The query that reads one job, its quote and its two timestamps.
5
+ FIND = <<~GRAPHQL
4
6
  query($id: EncodedId!) {
5
7
  job(id: $id) { id quote { id } startAt completedAt }
6
8
  }
7
9
  GRAPHQL
8
10
 
11
+ # @return [String, nil] the ID of the quote the job was won with.
9
12
  attr_reader :quote_id
10
13
 
14
+ # @param id [String] the Jobber ID of the job.
15
+ # @return [Job, nil] itself, or nil when Jobber has no such job.
11
16
  def find(id)
12
17
  output = @oauth.query FIND, variables: { id: id }
13
18
  return unless job = output['job']
@@ -1,5 +1,7 @@
1
1
  module Jbr
2
+ # The one account every mocked set of credentials belongs to.
2
3
  class Mock::Account < Account
4
+ # @return [String] the mocked account ID.
3
5
  def id = 'account-01'
4
6
  end
5
7
  end
@@ -1,5 +1,7 @@
1
1
  module Jbr
2
+ # An invoice that reads from {Jbr.mock} instead of Jobber.
2
3
  class Mock::Invoice < Invoice
4
+ # @return [Mock::Invoice] itself, carrying the mocked IDs and total.
3
5
  def find(_)
4
6
  @id = Jbr.mock.invoice[:id]
5
7
  @job_id = Jbr.mock.invoice[:job_id]
@@ -8,6 +10,7 @@ module Jbr
8
10
  self
9
11
  end
10
12
 
13
+ # @return [Time, nil] the dates the app asked for.
11
14
  def issued_at = Jbr.mock.invoice[:issued_at]
12
15
 
13
16
  def completed_at = Jbr.mock.invoice[:completed_at]
data/lib/jbr/mock/job.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  module Jbr
2
+ # A job that reads from {Jbr.mock} instead of Jobber.
2
3
  class Mock::Job < Job
4
+ # @return [Mock::Job] itself, carrying the mocked IDs.
3
5
  def find(_)
4
6
  @id = Jbr.mock.job[:id]
5
7
  @quote_id = Jbr.mock.job[:quote_id]
@@ -7,6 +9,7 @@ module Jbr
7
9
  self
8
10
  end
9
11
 
12
+ # @return [Time, nil] the times the app asked for.
10
13
  def scheduled_at = Jbr.mock.job[:scheduled_at]
11
14
 
12
15
  def completed_at = Jbr.mock.job[:completed_at]
@@ -1,19 +1,21 @@
1
1
  module Jbr
2
+ # Credentials that answer from {Jbr.mock} instead of Jobber.
2
3
  class Mock::OAuth < OAuth
4
+ # The mocked resources these credentials read and write.
3
5
  def invoices = Mock::Invoice.new(oauth: self)
4
6
  def jobs = Mock::Job.new(oauth: self)
5
7
  def quotes = Mock::Quote.new(oauth: self)
6
8
  def requests = Mock::Request.new(oauth: self)
7
9
  def account = Mock::Account.new oauth: self
8
10
 
11
+ # Revoking a mocked token asks nobody.
9
12
  def delete; end
10
13
 
11
- private
12
-
14
+ # @return [Hash] canned credentials, unless the app asked for a refusal.
13
15
  def self.post(_)
14
16
  raise Error, Jbr.mock.oauth_error if Jbr.mock.oauth_error
15
17
 
16
- { access_token: 'mock-token', refresh_token: 'mock-token', expires_at: (Time.current + 3600) }
18
+ { access_token: 'mock-token', refresh_token: 'mock-token', expires_at: (Time.now + 3600) }
17
19
  end
18
20
  end
19
21
  end
@@ -1,5 +1,7 @@
1
1
  module Jbr
2
+ # A quote that reads from {Jbr.mock} instead of Jobber.
2
3
  class Mock::Quote < Quote
4
+ # @return [Mock::Quote] itself, carrying the mocked IDs.
3
5
  def find(_)
4
6
  @id = Jbr.mock.quote[:id]
5
7
  @request_id = Jbr.mock.quote[:request_id]
@@ -1,5 +1,7 @@
1
1
  module Jbr
2
+ # A request that records what {Jbr.mock} was told to answer.
2
3
  class Mock::Request < Request
4
+ # @return [Mock::Request] itself, carrying the mocked IDs.
3
5
  def create(_)
4
6
  @id = Jbr.mock.request[:id]
5
7
  @client_id = Jbr.mock.request[:client_id]
data/lib/jbr/mock/url.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  module Jbr
2
+ # The authorize URL an app under test asked for.
2
3
  class Mock::URL < URL
4
+ # @return [String] whatever {Jbr.mock} was told to answer.
3
5
  def self.for(_)
4
6
  Jbr.mock.oauth_url
5
7
  end
data/lib/jbr/mock.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  module Jbr
2
+ # What an app under test wants Jobber to answer.
2
3
  class Mock
4
+ # The canned answers, each read by the matching Mock resource.
3
5
  attr_accessor :quote, :job, :invoice, :request, :oauth_url, :oauth_error
4
6
  end
5
7
  end
data/lib/jbr/mocking.rb CHANGED
@@ -1,21 +1,30 @@
1
1
  module Jbr
2
+ # The switch an app under test throws to answer Jobber without a network. Touching
3
+ # {#mock} once turns every entry point below over to its Mock counterpart.
2
4
  module Mocking
5
+ # @return [Mock] the answers this process gives, created on first use.
3
6
  def mock
4
7
  @mock ||= Jbr::Mock.new
5
8
  end
6
9
 
10
+ # @param params [Hash] the +code+ and +redirect_uri+ to exchange.
11
+ # @return [OAuth] credentials for the account that authorized the app.
7
12
  def create_oauth(params = {})
8
- (@mock ? Mock::OAuth : OAuth).create **params
13
+ (@mock ? Mock::OAuth : OAuth).create(**params)
9
14
  end
10
15
 
16
+ # @param params [Hash] the +redirect_uri+ and +state+ to come back with.
17
+ # @return [String] the URL a Jobber user authorizes the app on.
11
18
  def oauth_url_for(params = {})
12
- (@mock ? Mock::URL : URL).for **params
19
+ (@mock ? Mock::URL : URL).for(**params)
13
20
  end
14
21
 
22
+ # @param params [Hash] credentials already on file.
23
+ # @return [OAuth] those credentials, ready to read and write with.
15
24
  def oauth_for(params = {})
16
- (@mock ? Mock::OAuth : OAuth).new params
25
+ (@mock ? Mock::OAuth : OAuth).new(params)
17
26
  end
18
27
  end
19
28
 
20
29
  extend Mocking
21
- end
30
+ end
data/lib/jbr/oauth.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  module Jbr
2
+ # Credentials for one Jobber account, and the gateway to everything read or written
3
+ # with them. An expired access token is refreshed and the call retried once.
2
4
  class OAuth
3
- DISCONNECT_MUTATION = <<~GRAPHQL.freeze
5
+ # The mutation that revokes the app on the account.
6
+ DISCONNECT_MUTATION = <<~GRAPHQL
4
7
  mutation Disconnect {
5
8
  appDisconnect {
6
9
  app { name author }
@@ -9,6 +12,7 @@ module Jbr
9
12
  }
10
13
  GRAPHQL
11
14
 
15
+ # @param credentials [Hash] the tokens, their expiry, the account and when it went bad.
12
16
  def initialize(credentials = {})
13
17
  @access_token = credentials[:access_token]
14
18
  @refresh_token = credentials[:refresh_token]
@@ -17,9 +21,12 @@ module Jbr
17
21
  @invalid_at = credentials[:invalid_at]
18
22
  end
19
23
 
24
+ # The credentials as Jobber last gave them, plus the moment a refusal to refresh landed.
20
25
  attr_reader :access_token, :refresh_token, :expires_at, :invalid_at
26
+ # @return [String, nil] the account these credentials reach.
21
27
  attr_accessor :account_id
22
28
 
29
+ # The resources these credentials read and write.
23
30
  def account = Account.new oauth: self
24
31
  def clients = Client.new oauth: self
25
32
  def invoices = Invoice.new oauth: self
@@ -27,9 +34,13 @@ module Jbr
27
34
  def quotes = Quote.new oauth: self
28
35
  def requests = Request.new oauth: self
29
36
 
37
+ # Run a statement, refreshing the access token once if Jobber says it expired.
38
+ # @param statement [String] the query or mutation to run.
39
+ # @param variables [Hash] the variables it interpolates.
40
+ # @return [Hash] the data Jobber answered, or empty when the credentials are dead.
30
41
  def query(statement, variables: {})
31
42
  client.query statement, variables: variables
32
- rescue GraphQL::Unauthorized => e
43
+ rescue GraphQL::Unauthorized
33
44
  refresh ? retry : {}
34
45
  end
35
46
 
@@ -39,6 +50,10 @@ module Jbr
39
50
  rescue GraphQL::Unauthorized => e
40
51
  end
41
52
 
53
+ # Exchange an authorization code for credentials, then learn their account.
54
+ # @param code [String] the code Jobber sent to the redirect URI.
55
+ # @param redirect_uri [String] the URI the code came back to.
56
+ # @return [OAuth] the new credentials.
42
57
  def self.create(code:, redirect_uri:)
43
58
  credentials = post code: code, redirect_uri: redirect_uri, grant_type: 'authorization_code'
44
59
  new(credentials).tap { |oauth| oauth.account_id = oauth.account.id }
@@ -50,6 +65,19 @@ module Jbr
50
65
  # @return [String, nil] The client secret to interact with the API.
51
66
  def self.client_secret = ENV['JOBBER_CLIENT_SECRET']
52
67
 
68
+ # Exchange a code or a refresh token for credentials. Public because #refresh
69
+ # reaches it through self.class, which a private class method forbids.
70
+ def self.post(params = {})
71
+ uri = URI 'https://api.getjobber.com/api/oauth/token'
72
+ response = Net::HTTP.post_form uri,
73
+ params.merge(client_id: client_id, client_secret: client_secret)
74
+ raise Error, response.body unless response.is_a? Net::HTTPSuccess
75
+ output = JSON.parse(response.body)
76
+ { access_token: output['access_token'], refresh_token: output['refresh_token'],
77
+ expires_at: (Time.now + output.fetch('expires_in', 3600).to_i),
78
+ }
79
+ end
80
+
53
81
  private
54
82
 
55
83
  def refresh
@@ -58,21 +86,13 @@ module Jbr
58
86
  @refresh_token = output[:refresh_token]
59
87
  @expires_at = output[:expires_at]
60
88
  rescue Error => e
61
- @invalid_at = Time.current
89
+ @invalid_at = Time.now
62
90
  false
63
91
  end
64
92
 
65
- def self.post(params = {})
66
- uri = URI 'https://api.getjobber.com/api/oauth/token'
67
- response = Net::HTTP.post_form uri, params.merge(client_id: client_id, client_secret: client_secret)
68
- raise Error, response.body unless response.is_a? Net::HTTPSuccess
69
- output = JSON.parse(response.body)
70
- { access_token: output['access_token'], refresh_token: output['refresh_token'],
71
- expires_at: (Time.current + output.fetch('expires_in', 3600).to_i) }
72
- end
73
-
74
93
  def client
75
- GraphQL::Client.new endpoint: 'https://api.getjobber.com/api/graphql', token: @access_token, headers: headers
94
+ GraphQL::Client.new endpoint: 'https://api.getjobber.com/api/graphql',
95
+ token: @access_token, headers: headers
76
96
  end
77
97
 
78
98
  def headers = { 'X-JOBBER-GRAPHQL-VERSION' => '2026-04-22' }
@@ -0,0 +1,55 @@
1
+ module Jbr
2
+ # Where the work happens: one address on a client's file.
3
+ class Property < Resource
4
+ # The mutation that adds a property to a client already on file.
5
+ CREATE = <<~GRAPHQL
6
+ mutation propertyCreateMutation($clientId: EncodedId!, $input: PropertyCreateInput!) {
7
+ propertyCreate(clientId: $clientId, input: $input) {
8
+ properties { id }
9
+ userErrors { message }
10
+ }
11
+ }
12
+ GRAPHQL
13
+
14
+ # What Jobber calls each address field, against what a caller passes.
15
+ FIELDS = { street1: :street, city: :city, province: :state, postalCode: :zip }
16
+
17
+ # The fields a match is made on. City and state are written but never matched:
18
+ # Jobber holds whatever was typed, so "NC" and "North Carolina" -- or "Winston Salem"
19
+ # and "Winston-Salem" -- would read as two homes. The ZIP already places the home.
20
+ MATCHED = %i[street1 postalCode]
21
+
22
+ # The address as Jobber takes it, from the fields a caller passes.
23
+ # @param fields [Hash] any of :street, :city, :state and :zip.
24
+ # @return [Hash] the address, without the fields the caller left out.
25
+ def self.address_from(fields = {})
26
+ FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.compact
27
+ end
28
+
29
+ # Reach the property at an address, adding one when none of the client's matches.
30
+ # @param client_id [String] the client the property belongs to.
31
+ # @param address [Hash] the fields the work happens at.
32
+ # @param existing [Array<Hash>] the properties already on the client's file.
33
+ # @return [String, nil] the property ID.
34
+ def find_or_create_for(client_id:, address:, existing: [])
35
+ wanted = self.class.address_from address
36
+ match = existing.find { |property| same_address? wanted, property['address'] }
37
+ return match['id'] if match
38
+
39
+ output = @oauth.query CREATE, variables: {
40
+ clientId: client_id, input: { properties: [ { address: wanted } ] },
41
+ }
42
+ (output&.dig('propertyCreate', 'properties')&.first || {})['id']
43
+ end
44
+
45
+ private
46
+
47
+ # Field by field, because Jobber answers every field it was asked for, nil included,
48
+ # while a caller's address carries only what they had -- an absent field and a nil
49
+ # one are the same address. All of {MATCHED} has to agree: a home with no street
50
+ # parsed must not match the one house on the client's file that does have one.
51
+ def same_address?(wanted, address)
52
+ MATCHED.all? { |field| wanted[field] == (address || {})[field.to_s] }
53
+ end
54
+ end
55
+ end
data/lib/jbr/quote.rb CHANGED
@@ -1,13 +1,18 @@
1
1
  module Jbr
2
+ # A price a Jobber user sent to their client.
2
3
  class Quote < Resource
3
- FIND = <<~GRAPHQL.freeze
4
+ # The query that reads one quote and the request it came from.
5
+ FIND = <<~GRAPHQL
4
6
  query($id: EncodedId!) {
5
7
  quote(id: $id) { id request { id } }
6
8
  }
7
9
  GRAPHQL
8
10
 
11
+ # @return [String, nil] the ID of the request the quote answers.
9
12
  attr_reader :request_id
10
13
 
14
+ # @param id [String] the Jobber ID of the quote.
15
+ # @return [Quote, nil] itself, or nil when Jobber has no such quote.
11
16
  def find(id)
12
17
  output = @oauth.query FIND, variables: { id: id }
13
18
  return unless quote = output['quote']
data/lib/jbr/request.rb CHANGED
@@ -1,11 +1,14 @@
1
1
  module Jbr
2
+ # A lead on a Jobber user's board: work someone asked them for.
2
3
  class Request < Resource
3
- CREATE = <<~GRAPHQL.freeze
4
+ # The mutation that opens a request against a client and a property.
5
+ CREATE = <<~GRAPHQL
4
6
  mutation($input: RequestCreateInput!) {
5
7
  requestCreate(input: $input) { request { id property { id } } userErrors { message } }
6
8
  }
7
9
  GRAPHQL
8
10
 
11
+ # @return [String, nil] the client the request was opened against.
9
12
  attr_reader :client_id
10
13
 
11
14
  # Create a lead in Jobber associated to a new or existing client, matched by phone.
@@ -24,7 +27,7 @@ module Jbr
24
27
 
25
28
  input = {
26
29
  clientId: @client_id, title: params[:title], propertyId: @property_id,
27
- assessment: { instructions: params[:instructions] }
30
+ assessment: { instructions: params[:instructions] },
28
31
  }
29
32
  output = @oauth.query CREATE, variables: { input: input }
30
33
  @id = output.dig 'requestCreate', 'request', 'id'
data/lib/jbr/resource.rb CHANGED
@@ -1,9 +1,12 @@
1
1
  module Jbr
2
+ # What every Jobber resource shares: the credentials it is read or written through.
2
3
  class Resource
4
+ # @param oauth [OAuth] the credentials to reach Jobber with.
3
5
  def initialize(oauth:)
4
6
  @oauth = oauth
5
7
  end
6
8
 
9
+ # @return [String, nil] the Jobber ID, once the resource has been read or created.
7
10
  attr_reader :id
8
11
  end
9
12
  end
data/lib/jbr/url.rb CHANGED
@@ -1,13 +1,16 @@
1
1
  module Jbr
2
+ # The page a Jobber user authorizes the app on.
2
3
  class URL
4
+ # @param params [Hash] the +redirect_uri+ and +state+ to come back with.
5
+ # @return [String] the URL to send the user to.
3
6
  def self.for(params = {})
4
7
  uri = URI 'https://api.getjobber.com/api/oauth/authorize'
5
- uri.query ||= params.merge(response_type: 'code', client_id: client_id).to_query
8
+ uri.query = URI.encode_www_form params.merge(response_type: 'code', client_id: client_id)
6
9
  uri.to_s
7
10
  end
8
11
 
9
- private
10
-
12
+ # @return [String, nil] the client ID of the app being authorized.
11
13
  def self.client_id = ENV['JOBBER_CLIENT_ID']
14
+ private_class_method :client_id
12
15
  end
13
16
  end
data/lib/jbr/version.rb CHANGED
@@ -1,5 +1,5 @@
1
- # frozen_string_literal: true
2
-
1
+ # A Ruby client for the Jobber API.
3
2
  module Jbr
4
- VERSION = '1.2.0'
3
+ # The version of this gem.
4
+ VERSION = '2.0.0'
5
5
  end
data/lib/jbr.rb CHANGED
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require 'json'
4
2
  require 'net/http'
5
3
 
@@ -19,6 +17,7 @@ require 'jbr/account'
19
17
  require 'jbr/client'
20
18
  require 'jbr/invoice'
21
19
  require 'jbr/job'
20
+ require 'jbr/property'
22
21
  require 'jbr/quote'
23
22
 
24
23
  require 'jbr/mock/oauth'
metadata CHANGED
@@ -1,14 +1,84 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jbr
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop-rails-omakase
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: simplecov
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: webmock
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
12
82
  description: Jobber API
13
83
  email:
14
84
  - claudiob@users.noreply.github.com
@@ -16,6 +86,7 @@ executables: []
16
86
  extensions: []
17
87
  extra_rdoc_files: []
18
88
  files:
89
+ - ".rubocop.yml"
19
90
  - CHANGELOG.md
20
91
  - LICENSE.txt
21
92
  - README.md
@@ -25,7 +96,6 @@ files:
25
96
  - lib/jbr.rb
26
97
  - lib/jbr/account.rb
27
98
  - lib/jbr/client.rb
28
- - lib/jbr/configuration.rb
29
99
  - lib/jbr/error.rb
30
100
  - lib/jbr/event.rb
31
101
  - lib/jbr/invoice.rb
@@ -40,6 +110,7 @@ files:
40
110
  - lib/jbr/mock/url.rb
41
111
  - lib/jbr/mocking.rb
42
112
  - lib/jbr/oauth.rb
113
+ - lib/jbr/property.rb
43
114
  - lib/jbr/quote.rb
44
115
  - lib/jbr/request.rb
45
116
  - lib/jbr/resource.rb
@@ -1,5 +0,0 @@
1
- module Jbr
2
- class Configuration
3
- attr_accessor :mock_account, :mock_request, :mock_auth, :mock_auth_error, :mock_disconnect, :mock_invoice, :mock_job, :mock_quote, :mock_redirect_url
4
- end
5
- end