jbr 2.0.0 → 2.2.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: 6c3baeab4ed52ddaf9e86a1086b34b84da47a9bce32a46e05036145dc1c9ca86
4
- data.tar.gz: e0c89dd53a6e64a15bba3b748b1689f2cc063030ba64ed13b18364d1768a6f99
3
+ metadata.gz: c2e03e9b80223954e92497d75f84d2f6be78f23fadcbcfd7d82ba95e83ebacec
4
+ data.tar.gz: aac7754bb691e5b45ec8284a289eceacfc9afc961cbb71e6ffedc2f92a5fd2a9
5
5
  SHA512:
6
- metadata.gz: cd207ad99f2769ae55bcf2a4f5b149118bafa49f6638d18a4e4a9661ff5729cb1fd1398b67af68cfb17dd251a851b46a350835621a34ed3f888f3676e774d621
7
- data.tar.gz: d8dc7694dff3aa8ef51480ab51500c94903cf0118f6d075e9b8ada9fae761dcb7f91f133a9eb7ee4894c33a18fab59645ae138ccd4aec367fa5c0ccbf50c0a9f
6
+ metadata.gz: 1a962bbdc7d20e9549fb10d55a5c52ddb1de7ce7c92a9da20e61162f040a8b2da35c2c6daa545c4ef67e36b04eec0085fa03faddf10db6435674390c20ddacde
7
+ data.tar.gz: e565f59d6f14284fd3919a048f770e060588be06850d225947b4552d88b1201ea7149afec5fe733f57d78afaa1809ea762f62377a7596729917381ecd7d65e7f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [2.2.0] - 2026-08-07
2
+
3
+ - [New] Carry the client a visit is for -- id, first name, last name, phone and email --
4
+ and the ID of the property it happens at, so an app can file a visit against the people
5
+ and places it already knows
6
+
7
+ ## [2.1.0] - 2026-08-07
8
+
9
+ - [New] Fetch the visits an account has scheduled from now on, with oauth.visits.upcoming.
10
+ Each carries its job, its times, whether it takes the whole day, whether the client
11
+ confirmed it, and the address of the property it happens at, in the same fields
12
+ Jbr::Property takes. It answers an Enumerator, so a page is read only once the one
13
+ before it runs out
14
+ - [New] Read a property's latitude and longitude, beside its street rather than under a
15
+ hash of their own. Every query that reads a property asks for them
16
+
1
17
  ## [2.0.0] - 2026-08-07
2
18
 
3
19
  - [Fix] Require nothing but the standard library: to_query, present?, pluck,
data/README.md CHANGED
@@ -86,6 +86,29 @@ invoice.issued_at # => 2026-05-22 12:12:53
86
86
  invoice.completed_at # => 2026-05-22 14:32:53
87
87
  ```
88
88
 
89
+ ### Visits
90
+
91
+ Fetch the visits scheduled from now on, oldest first. Jobber is asked for a page at a time,
92
+ and only once the page before it runs out, so `first` costs one request where `to_a` costs
93
+ as many as the account has pages:
94
+
95
+ ```ruby
96
+ visits = oauth.visits.upcoming # => an Enumerator, nothing fetched yet
97
+ visit = visits.first
98
+ visit.id # => 'Z2lkOi8vS'
99
+ visit.title # => 'Furnace tune-up'
100
+ visit.job_id # => 'Z2lkOi8vS'
101
+ visit.property_id # => 'Z2lkOi8vS'
102
+ visit.client # => { id: 'Z2lkOi8vS', first_name: 'Jane', last_name: 'Doe',
103
+ # phone: '5553335555', email: 'jane@example.com' }
104
+ visit.address # => { street: '1 Main St', city: 'Raleigh', state: 'NC', zip: '27601',
105
+ # latitude: 35.77, longitude: -78.63 }
106
+ visit.starts_at # => 2026-08-09 14:00:00
107
+ visit.ends_at # => 2026-08-09 16:00:00
108
+ visit.all_day? # => false
109
+ visit.client_confirmed? # => true
110
+ ```
111
+
89
112
  ### Events
90
113
 
91
114
  Parse the payload of a Jobber event webhook:
@@ -145,6 +168,18 @@ Mock successfully fetching a job:
145
168
  Jbr.mock.job = { id: 'job-01', quote_id: 'quote-01', scheduled_at: Date.tomorrow.noon }
146
169
  ```
147
170
 
171
+ ### Visits
172
+
173
+ Mock successfully fetching upcoming visits:
174
+
175
+ ```ruby
176
+ Jbr.mock.visits = [ { id: 'visit-01', title: 'Furnace tune-up', job_id: 'job-01',
177
+ property_id: 'property-01', client: { id: 'client-01', first_name: 'Jane' },
178
+ address: { street: '1 Main St', city: 'Raleigh', state: 'NC', zip: '27601' },
179
+ starts_at: Date.tomorrow.noon, ends_at: Date.tomorrow.end_of_day,
180
+ all_day: false, client_confirmed: true } ]
181
+ ```
182
+
148
183
  ### Invoices
149
184
 
150
185
  Mock successfully fetching an invoice:
data/lib/jbr/client.rb CHANGED
@@ -5,7 +5,7 @@ module Jbr
5
5
  LOOKUP = <<~GRAPHQL
6
6
  query($searchTerm: String!) {
7
7
  clientPhones(first: 1, searchTerm: $searchTerm) { nodes {
8
- client { id updatedAt clientProperties { nodes { id address { street1 city province postalCode } }} }
8
+ client { id updatedAt clientProperties { nodes { id address { #{Property::SELECTION} } }} }
9
9
  } }
10
10
  }
11
11
  GRAPHQL
@@ -7,6 +7,7 @@ module Jbr
7
7
  def quotes = Mock::Quote.new(oauth: self)
8
8
  def requests = Mock::Request.new(oauth: self)
9
9
  def account = Mock::Account.new oauth: self
10
+ def visits = Mock::Visit.new(oauth: self)
10
11
 
11
12
  # Revoking a mocked token asks nobody.
12
13
  def delete; end
@@ -0,0 +1,32 @@
1
+ module Jbr
2
+ # A visit that reads from {Jbr.mock} instead of Jobber.
3
+ class Mock::Visit < Visit
4
+ # @return [Enumerator<Mock::Visit>] the visits the app asked for.
5
+ def upcoming
6
+ Enumerator.new do |yielder|
7
+ Jbr.mock.visits.each { |visit| yielder << self.class.new(oauth: @oauth, node: visit) }
8
+ end
9
+ end
10
+
11
+ # @return [Object, nil] the values the app asked for.
12
+ def id = @node[:id]
13
+
14
+ def title = @node[:title]
15
+
16
+ def job_id = @node[:job_id]
17
+
18
+ def property_id = @node[:property_id]
19
+
20
+ def client = @node.fetch :client, {}
21
+
22
+ def address = @node.fetch :address, {}
23
+
24
+ def all_day? = @node[:all_day]
25
+
26
+ def client_confirmed? = @node[:client_confirmed]
27
+
28
+ def starts_at = @node[:starts_at]
29
+
30
+ def ends_at = @node[:ends_at]
31
+ end
32
+ end
data/lib/jbr/mock.rb CHANGED
@@ -2,6 +2,6 @@ module Jbr
2
2
  # What an app under test wants Jobber to answer.
3
3
  class Mock
4
4
  # The canned answers, each read by the matching Mock resource.
5
- attr_accessor :quote, :job, :invoice, :request, :oauth_url, :oauth_error
5
+ attr_accessor :quote, :job, :invoice, :request, :visits, :oauth_url, :oauth_error
6
6
  end
7
7
  end
data/lib/jbr/oauth.rb CHANGED
@@ -1,6 +1,5 @@
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
+ # Credentials for one Jobber account, and the gateway to all they read or write.
4
3
  class OAuth
5
4
  # The mutation that revokes the app on the account.
6
5
  DISCONNECT_MUTATION = <<~GRAPHQL
@@ -33,6 +32,7 @@ module Jbr
33
32
  def jobs = Job.new oauth: self
34
33
  def quotes = Quote.new oauth: self
35
34
  def requests = Request.new oauth: self
35
+ def visits = Visit.new oauth: self
36
36
 
37
37
  # Run a statement, refreshing the access token once if Jobber says it expired.
38
38
  # @param statement [String] the query or mutation to run.
data/lib/jbr/property.rb CHANGED
@@ -14,6 +14,12 @@ module Jbr
14
14
  # What Jobber calls each address field, against what a caller passes.
15
15
  FIELDS = { street1: :street, city: :city, province: :state, postalCode: :zip }
16
16
 
17
+ # The two Jobber tucks inside the address and a property carries beside the street.
18
+ COORDINATES = %i[latitude longitude]
19
+
20
+ # The address as Jobber answers it, asked for wherever a property is read.
21
+ SELECTION = "#{FIELDS.keys.join ' '} coordinates { #{COORDINATES.join ' '} }"
22
+
17
23
  # The fields a match is made on. City and state are written but never matched:
18
24
  # Jobber holds whatever was typed, so "NC" and "North Carolina" -- or "Winston Salem"
19
25
  # and "Winston-Salem" -- would read as two homes. The ZIP already places the home.
@@ -26,6 +32,17 @@ module Jbr
26
32
  FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.compact
27
33
  end
28
34
 
35
+ # The fields a caller reads, from the address as Jobber holds it.
36
+ # @param address [Hash, nil] the address Jobber answered, if it answered one.
37
+ # @return [Hash] any of :street, :city, :state, :zip, :latitude and :longitude,
38
+ # without the ones Jobber left out.
39
+ def self.fields_from(address)
40
+ address ||= {}
41
+ coordinates = address['coordinates'] || {}
42
+ FIELDS.to_h { |jobber, ours| [ ours, address[jobber.to_s] ] }.
43
+ merge(COORDINATES.to_h { |ours| [ ours, coordinates[ours.to_s] ] }).compact
44
+ end
45
+
29
46
  # Reach the property at an address, adding one when none of the client's matches.
30
47
  # @param client_id [String] the client the property belongs to.
31
48
  # @param address [Hash] the fields the work happens at.
data/lib/jbr/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # A Ruby client for the Jobber API.
2
2
  module Jbr
3
3
  # The version of this gem.
4
- VERSION = '2.0.0'
4
+ VERSION = '2.2.0'
5
5
  end
data/lib/jbr/visit.rb ADDED
@@ -0,0 +1,96 @@
1
+ module Jbr
2
+ # One stop at a property: when the work on a job is scheduled to happen.
3
+ class Visit < Resource
4
+ # What Jobber calls each field of the client the work is for, against what a caller reads.
5
+ CLIENT_FIELDS = { id: :id, firstName: :first_name, lastName: :last_name,
6
+ phone: :phone, email: :email,
7
+ }
8
+
9
+ # The query that reads a page of visits starting after a moment, oldest first. Forty a
10
+ # page, not a hundred: Jobber prices a query by its page size and refuses the wider one.
11
+ UPCOMING = <<~GRAPHQL
12
+ query($after: String, $from: ISO8601DateTime!) {
13
+ visits(first: 40, after: $after, filter: { startAt: { after: $from } }) {
14
+ nodes {
15
+ id title startAt endAt allDay clientConfirmed
16
+ job { id }
17
+ client { #{CLIENT_FIELDS.keys.join ' '} }
18
+ property { id address { #{Property::SELECTION} } }
19
+ }
20
+ pageInfo { hasNextPage endCursor }
21
+ }
22
+ }
23
+ GRAPHQL
24
+
25
+ # @param oauth [OAuth] the credentials to reach Jobber with.
26
+ # @param node [Hash] the visit as Jobber answered it.
27
+ def initialize(oauth:, node: {})
28
+ super oauth: oauth
29
+ @node = node
30
+ end
31
+
32
+ # The visits scheduled from now on, oldest first. Nothing is read until the enumerator
33
+ # is walked, and a page is read only once the one before it runs out.
34
+ # @return [Enumerator<Visit>] the account's upcoming visits.
35
+ def upcoming
36
+ Enumerator.new do |yielder|
37
+ nodes.each { |node| yielder << self.class.new(oauth: @oauth, node: node) }
38
+ end
39
+ end
40
+
41
+ # @return [String, nil] the Jobber ID of the visit.
42
+ def id = @node['id']
43
+
44
+ # @return [String, nil] what the visit is called.
45
+ def title = @node['title']
46
+
47
+ # @return [String, nil] the ID of the job the visit belongs to.
48
+ def job_id = @node.dig 'job', 'id'
49
+
50
+ # @return [Boolean, nil] whether the visit takes the whole day rather than an hour of it.
51
+ def all_day? = @node['allDay']
52
+
53
+ # @return [Boolean, nil] whether the client has confirmed the visit.
54
+ def client_confirmed? = @node['clientConfirmed']
55
+
56
+ # @return [String, nil] the ID of the property the work happens at.
57
+ def property_id = @node.dig 'property', 'id'
58
+
59
+ # Who the work is for, in the fields a client is created with.
60
+ # @return [Hash] any of :id, :first_name, :last_name, :phone and :email.
61
+ def client
62
+ CLIENT_FIELDS.to_h { |jobber, ours| [ ours, @node.dig('client', jobber.to_s) ] }.compact
63
+ end
64
+
65
+ # Where the work happens, in the fields {Property} carries.
66
+ # @return [Hash] any of :street, :city, :state, :zip, :latitude and :longitude.
67
+ def address = Property.fields_from @node.dig('property', 'address')
68
+
69
+ # @return [Time, nil] the visit start time
70
+ def starts_at
71
+ Time.iso8601(@node['startAt']) if @node['startAt']
72
+ end
73
+
74
+ # @return [Time, nil] the visit end time
75
+ def ends_at
76
+ Time.iso8601(@node['endAt']) if @node['endAt']
77
+ end
78
+
79
+ private
80
+
81
+ # The moment is stamped once, before the first page: read per page, it would slide
82
+ # forward and drop a visit that started while the pages were being walked.
83
+ def nodes
84
+ Enumerator.new do |yielder|
85
+ from, after = Time.now.iso8601, nil
86
+ loop do
87
+ page = @oauth.query(UPCOMING, variables: { after: after, from: from }).fetch 'visits', {}
88
+ page.fetch('nodes', []).each { |node| yielder << node }
89
+ break unless page.dig 'pageInfo', 'hasNextPage'
90
+
91
+ after = page.dig 'pageInfo', 'endCursor'
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
data/lib/jbr.rb CHANGED
@@ -14,11 +14,13 @@ require 'jbr/request'
14
14
  require 'jbr/oauth'
15
15
 
16
16
  require 'jbr/account'
17
+ # Property comes before Client and Visit: their queries read its fields as they load.
18
+ require 'jbr/property'
17
19
  require 'jbr/client'
18
20
  require 'jbr/invoice'
19
21
  require 'jbr/job'
20
- require 'jbr/property'
21
22
  require 'jbr/quote'
23
+ require 'jbr/visit'
22
24
 
23
25
  require 'jbr/mock/oauth'
24
26
  require 'jbr/mock/quote'
@@ -27,6 +29,7 @@ require 'jbr/mock/invoice'
27
29
  require 'jbr/mock/request'
28
30
  require 'jbr/mock/account'
29
31
  require 'jbr/mock/url'
32
+ require 'jbr/mock/visit'
30
33
 
31
34
  require 'jbr/mocking'
32
35
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jbr
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -108,6 +108,7 @@ files:
108
108
  - lib/jbr/mock/quote.rb
109
109
  - lib/jbr/mock/request.rb
110
110
  - lib/jbr/mock/url.rb
111
+ - lib/jbr/mock/visit.rb
111
112
  - lib/jbr/mocking.rb
112
113
  - lib/jbr/oauth.rb
113
114
  - lib/jbr/property.rb
@@ -116,6 +117,7 @@ files:
116
117
  - lib/jbr/resource.rb
117
118
  - lib/jbr/url.rb
118
119
  - lib/jbr/version.rb
120
+ - lib/jbr/visit.rb
119
121
  homepage: https://github.com/HouseAccountEng/jbr
120
122
  licenses:
121
123
  - MIT