jbr 2.4.0 → 3.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: 131ece92fb051e64674afcd8e0066a126c83cd4502b7ea570795e2f6a456aaf6
4
- data.tar.gz: d56615c6a47fab3de06ecebf672ade7f355e2338e263ff23577faea1b3dd744d
3
+ metadata.gz: f70cfb9bf360eac2836271beef56dc7b2da9c5f2355b8fb4ac51c39c3573f2e8
4
+ data.tar.gz: cd50eee427d9a2b985a8eb023b1b1afe99a784574d87c52a019fff90e399da47
5
5
  SHA512:
6
- metadata.gz: 12e0d034ad5a76d89bf9d8ded9b84bd5790197e34a709404c551f9b75d9d697dcaec066db7f9bd05283bb17fabbee9a5b1239f29b5ed90bb025141f47b9db88a
7
- data.tar.gz: 1c3b8eff188c1a4ade48f273c6ec575bfe57a9906da717b8fba10237e3722317f04fc1bacbb333b06b2df4753e3dcf3c5e5d203a2d970908136d55098f3009d9
6
+ metadata.gz: 2ddb2eb5dd395b8840ce9b23c55537b6161248002aa131c2759bebbf0dde6bcdfa1c729eadbad3483aead96a232aa258d77a3afed44351f861ac0cffc4ab5729
7
+ data.tar.gz: e95678d5a04baa3a9811b261682a6cbf71bc588ca8eb3eb78f9f390b53e0928c42798129705a57e71f4cb6ab46905a3dc520e000219d93dfbf2ad33dfb597157
data/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ ## [3.0.0] - 2026-08-13
2
+
3
+ - [Breaking change] `oauth.visits` and `oauth.jobs` answer the whole collection rather than
4
+ one record: each is an Enumerable of every visit or job on the account, oldest first, read
5
+ a page at a time only as far as it is walked. `oauth.visits.upcoming` still answers the
6
+ ones dated from now on, `.past` answers the ones dated before now, and both split the
7
+ schedule at the moment they are asked rather than per page, so nothing crosses the
8
+ boundary unseen. `oauth.jobs.find(id)` is unchanged
9
+ - [Breaking change] A visit or a job answers `client` and `property` as objects rather than
10
+ hashes: `visit.client.phone` and `visit.property.street` where `visit.client[:phone]` and
11
+ `visit.property[:street]` used to read. A property answers `client` too, so the person a
12
+ place sits on the file of comes back with the place
13
+ - [Breaking change] Nothing nested is fetched unless it is asked for. Chain `includes` the
14
+ way Active Record does -- `oauth.visits.includes(:client, property: :client)` -- and only
15
+ what it names is added to the query. Jobber prices a query by what it brings back, so a
16
+ caller who wants the visit alone is no longer charged for the client and the place
17
+ - [New] Fetch jobs the way visits are fetched. A job carries `name`, `title`,
18
+ `instructions`, `status`, `total`, `quote_id`, `quote_total`, `created_at`, `scheduled_at`
19
+ and `completed_at`, and its client and property come back with `includes`. `name` is the
20
+ title, or the ID Jobber files the job under where nobody titled it, so it is never nil
21
+ - [New] A client answers `name`: their first name, or the name of the business where the
22
+ client is a business. Jobber files nobody without one or the other, so it is never nil.
23
+ `first_name`, `last_name`, `company_name`, `email` and `phone` read individually
24
+ - [New] Mock the jobs an account has with `Jbr.mock.jobs`, beside `Jbr.mock.visits`
25
+
1
26
  ## [2.4.0] - 2026-08-08
2
27
 
3
28
  - [Breaking change] A visit answers `property` -- the ID Jobber files the place under, beside
data/README.md CHANGED
@@ -63,7 +63,7 @@ quote.request_id # => 'Z2lkOi8vSm9iYmVyL'
63
63
 
64
64
  ### Jobs
65
65
 
66
- Fetch a job from Jobber:
66
+ Fetch a job from Jobber by the ID it is filed under:
67
67
 
68
68
  ```ruby
69
69
  job = oauth.jobs.find 'Njc5MTk5'
@@ -73,6 +73,25 @@ job.scheduled_at # => 2026-05-14 23:02:52
73
73
  job.completed_at # => 2026-05-18 11:36:13
74
74
  ```
75
75
 
76
+ Or walk the account's jobs, oldest first. Jobber is asked for a page at a time, and only
77
+ once the page before it runs out, so `first` costs one request where `to_a` costs as many
78
+ as the account has pages:
79
+
80
+ ```ruby
81
+ jobs = oauth.jobs # => an Enumerable of every job, nothing fetched yet
82
+ oauth.jobs.past # => an Enumerator of the ones dated before now
83
+ oauth.jobs.upcoming # => an Enumerator of the ones dated from now on
84
+
85
+ job = jobs.first
86
+ job.name # => 'Furnace tune-up', or the job's ID where nobody titled it. Never nil
87
+ job.title # => 'Furnace tune-up'
88
+ job.instructions # => 'Ring the doorbell twice'
89
+ job.status # => 'requires_invoicing'
90
+ job.total # => 260.0
91
+ job.quote_total # => 240.0
92
+ job.created_at # => 2026-05-10 09:15:00
93
+ ```
94
+
76
95
  ### Invoices
77
96
 
78
97
  Fetch a non-draft invoice from Jobber:
@@ -88,27 +107,50 @@ invoice.completed_at # => 2026-05-22 14:32:53
88
107
 
89
108
  ### Visits
90
109
 
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:
110
+ Walk the account's visits, oldest first, the same way as its jobs:
94
111
 
95
112
  ```ruby
96
- visits = oauth.visits.upcoming # => an Enumerator, nothing fetched yet
113
+ visits = oauth.visits # => an Enumerable of every visit, nothing fetched yet
114
+ oauth.visits.upcoming # => an Enumerator of the ones dated from now on
115
+ oauth.visits.past # => an Enumerator of the ones dated before now
116
+
97
117
  visit = visits.first
98
118
  visit.id # => 'Z2lkOi8vS'
99
119
  visit.title # => 'Furnace tune-up'
100
120
  visit.job_id # => 'Z2lkOi8vS'
101
- visit.property # => { id: 'Z2lkOi8vS', street: '1 Main St', city: 'Raleigh',
102
- # state: 'NC', zip: '27601', latitude: 35.77, longitude: -78.63 }
103
- visit.client # => { id: 'Z2lkOi8vS', first_name: 'Jane', last_name: 'Doe',
104
- # phone: '5553335555', email: 'jane@example.com' }
105
- # phone is the reachable North American number, ten digits, or nil
106
121
  visit.starts_at # => 2026-08-09 14:00:00
107
122
  visit.ends_at # => 2026-08-09 16:00:00
108
123
  visit.all_day? # => false
109
124
  visit.client_confirmed? # => true
110
125
  ```
111
126
 
127
+ ### Clients and properties
128
+
129
+ Jobber prices a query by what it brings back, so nothing nested comes back unless it is
130
+ asked for. Chain `includes` the way Active Record does, on visits or on jobs:
131
+
132
+ ```ruby
133
+ visit = oauth.visits.includes(:client, property: :client).upcoming.first
134
+
135
+ visit.client.name # => 'Jane', or the business's name where the client is a business
136
+ visit.client.first_name # => 'Jane'
137
+ visit.client.last_name # => 'Doe'
138
+ visit.client.company_name # => nil
139
+ visit.client.email # => 'jane@example.com'
140
+ visit.client.phone # => '5553335555', the reachable North American number, or nil
141
+
142
+ visit.property.id # => 'Z2lkOi8vS'
143
+ visit.property.street # => '1 Main St'
144
+ visit.property.city # => 'Raleigh'
145
+ visit.property.zip # => '27601'
146
+ visit.property.address # => { street: '1 Main St', city: 'Raleigh', state: 'NC',
147
+ # zip: '27601', latitude: 35.77, longitude: -78.63 }
148
+ visit.property.client.name # => whoever the place sits on the file of
149
+ ```
150
+
151
+ Ask for nothing and nothing arrives: `oauth.visits.first.client.name` is nil where the
152
+ query never named a client.
153
+
112
154
  ### Events
113
155
 
114
156
  Parse the payload of a Jobber event webhook:
@@ -162,21 +204,32 @@ Jbr.mock.quote = { id: 'quote-01', request_id: 'request-01' }
162
204
 
163
205
  ### Jobs
164
206
 
165
- Mock successfully fetching a job:
207
+ Mock successfully fetching a job by ID:
166
208
 
167
209
  ```ruby
168
210
  Jbr.mock.job = { id: 'job-01', quote_id: 'quote-01', scheduled_at: Date.tomorrow.noon }
169
211
  ```
170
212
 
213
+ Mock the jobs the account has. The mock dates nothing it was handed: what answers to
214
+ `past` and to `upcoming` is whatever `scheduled_at` the app gave each one:
215
+
216
+ ```ruby
217
+ Jbr.mock.jobs = [ { id: 'job-01', title: 'Furnace tune-up', status: 'archived',
218
+ total: 260.0, quote_total: 240.0, created_at: Date.yesterday.noon,
219
+ scheduled_at: Date.yesterday.noon, completed_at: Date.today.noon,
220
+ property: { id: 'property-01', street: '1 Main St',
221
+ client: { id: 'client-01', company_name: 'Acme Property Management' } } } ]
222
+ ```
223
+
171
224
  ### Visits
172
225
 
173
- Mock successfully fetching upcoming visits:
226
+ Mock the visits the account has:
174
227
 
175
228
  ```ruby
176
229
  Jbr.mock.visits = [ { id: 'visit-01', title: 'Furnace tune-up', job_id: 'job-01',
177
- property: { id: 'property-01', street: '1 Main St' },
230
+ property: { id: 'property-01', street: '1 Main St',
231
+ client: { id: 'client-01', first_name: 'Jane' } },
178
232
  client: { id: 'client-01', first_name: 'Jane' },
179
- address: { street: '1 Main St', city: 'Raleigh', state: 'NC', zip: '27601' },
180
233
  starts_at: Date.tomorrow.noon, ends_at: Date.tomorrow.end_of_day,
181
234
  all_day: false, client_confirmed: true } ]
182
235
  ```
data/lib/jbr/client.rb CHANGED
@@ -23,6 +23,25 @@ module Jbr
23
23
  # @return [String, nil] the property the work happens at.
24
24
  attr_reader :property_id
25
25
 
26
+ # @return [String, nil] what the client is called.
27
+ def first_name = @node['firstName']
28
+
29
+ def last_name = @node['lastName']
30
+
31
+ # @return [String, nil] the business the client is, where the client is a business.
32
+ def company_name = @node['companyName']
33
+
34
+ # Jobber files nobody without one name or the other, so there is always one to call them.
35
+ # @return [String] the person's first name, or the business's name.
36
+ def name = first_name || company_name
37
+
38
+ # @return [String, nil] the address to write to.
39
+ def email = @node['email']
40
+
41
+ # The number to reach them on, without its country code.
42
+ # @return [String, nil] ten digits, or nil where Jobber holds none we can dial.
43
+ def phone = Phone.from(@node['phones'])
44
+
26
45
  # Create a client instance with the provided attributes.
27
46
  # @return [Client] itself
28
47
  # @param params [Hash] the attributes of the client
@@ -0,0 +1,14 @@
1
+ module Jbr
2
+ # Extends a record that names the client the work is for: a visit, a job, a property,
3
+ # anything Jobber files under somebody.
4
+ module Cliental
5
+ # What Jobber calls each field of that client.
6
+ FIELDS = %w[id firstName lastName companyName email]
7
+
8
+ # What to ask for wherever a record names its client.
9
+ SELECTION = "client { #{FIELDS.join ' '} #{Phone::SELECTION} }"
10
+
11
+ # @return [Client] who the work is for.
12
+ def client = Client.new node: @node.fetch('client', {})
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ module Jbr
2
+ # Extends a list of records with the chaining that says what to bring back beside them.
3
+ # Nothing extra comes back unasked: a page costs what it carries.
4
+ module Includable
5
+ # @param names [Array<Symbol, Hash>] :client, :property, or property: :client for the
6
+ # client whose file the place sits on.
7
+ # @return [Resource] the same list, asking Jobber for those too.
8
+ def includes(*names)
9
+ named = names.each_with_object({}) do |name, all|
10
+ name.is_a?(Hash) ? all.merge!(name) : all[name] = nil
11
+ end
12
+ self.class.new oauth: @oauth, includes: @includes.merge(named)
13
+ end
14
+
15
+ private
16
+
17
+ # What the includes ask Jobber for, in its own words.
18
+ def selections = @includes.map { |name, nested| selection_of name, nested }.join ' '
19
+
20
+ def selection_of(name, nested)
21
+ case name
22
+ when :client then Cliental::SELECTION
23
+ when :property then Properted.selection client: nested == :client
24
+ end
25
+ end
26
+ end
27
+ end
data/lib/jbr/job.rb CHANGED
@@ -1,37 +1,37 @@
1
1
  module Jbr
2
2
  # Work a Jobber user accepted and scheduled.
3
3
  class Job < Resource
4
- # The query that reads one job, its quote and its two timestamps.
5
- FIND = <<~GRAPHQL
6
- query($id: EncodedId!) {
7
- job(id: $id) { id quote { id } startAt completedAt }
8
- }
9
- GRAPHQL
4
+ include Cliental, Properted
5
+
6
+ # @return [String, nil] what the job is called, where whoever opened it named it.
7
+ def title = @node['title']
8
+
9
+ # A job goes untitled often enough, and something has to stand in for it on a list.
10
+ # @return [String] the title, or the ID Jobber files the job under.
11
+ def name = title || id
12
+
13
+ # @return [String, nil] what the work is, in the words whoever opened the job wrote.
14
+ def instructions = @node['instructions']
15
+
16
+ # @return [String, nil] where Jobber files the job in its own workflow.
17
+ def status = @node['jobStatus']
10
18
 
11
19
  # @return [String, nil] the ID of the quote the job was won with.
12
- attr_reader :quote_id
13
-
14
- # @param id [String] the Jobber ID of the job.
15
- # @return [Job, nil] itself, or nil when Jobber has no such job.
16
- def find(id)
17
- output = @oauth.query FIND, variables: { id: id }
18
- return unless job = output['job']
19
-
20
- @id = job['id']
21
- @quote_id = job.dig 'quote', 'id'
22
- @scheduled_at = job['startAt']
23
- @completed_at = job['completedAt']
24
- self
25
- end
26
-
27
- # @return [Time] the job scheduled time
28
- def scheduled_at
29
- Time.iso8601(@scheduled_at) if @scheduled_at
30
- end
31
-
32
- # @return [Time] the job completed time
33
- def completed_at
34
- Time.iso8601(@completed_at) if @completed_at
35
- end
20
+ def quote_id = @node.dig 'quote', 'id'
21
+
22
+ # @return [Float, nil] what the job comes to.
23
+ def total = @node['total']
24
+
25
+ # @return [Float, nil] what the quote the job was won with came to.
26
+ def quote_total = @node.dig 'quote', 'amounts', 'total'
27
+
28
+ # @return [Time, nil] the job opening time
29
+ def created_at = time 'createdAt'
30
+
31
+ # @return [Time, nil] the job scheduled time
32
+ def scheduled_at = time 'startAt'
33
+
34
+ # @return [Time, nil] the job completed time
35
+ def completed_at = time 'completedAt'
36
36
  end
37
37
  end
data/lib/jbr/jobs.rb ADDED
@@ -0,0 +1,57 @@
1
+ module Jbr
2
+ # The jobs on a Jobber account, oldest first, walked a page at a time.
3
+ class Jobs < Resource
4
+ include Enumerable, Includable
5
+
6
+ # What a job answers with wherever one is read, before anything it was asked to bring
7
+ # back with it.
8
+ FIELDS = 'id title instructions jobStatus total createdAt startAt completedAt ' \
9
+ 'quote { id amounts { total } }'
10
+
11
+ # Every job on the account, past and future alike. Nothing is read until the walk starts,
12
+ # and a page is read only once the one before it runs out.
13
+ def each(&) = walk.each(&)
14
+
15
+ # @return [Enumerator<Job>] the jobs scheduled from now on.
16
+ def upcoming = walk from_now
17
+
18
+ # @return [Enumerator<Job>] the jobs that started before now.
19
+ def past = walk until_now
20
+
21
+ # Shadows Enumerable#find on purpose, the way Active Record does: a job is reached by the
22
+ # ID Jobber files it under, not by asking every job on the account whether it is the one.
23
+ # @param id [String] the Jobber ID of the job.
24
+ # @return [Job, nil] nil when Jobber has no job under that ID.
25
+ def find(id)
26
+ node = @oauth.query(one, variables: { id: id })['job']
27
+ Job.new node: node if node
28
+ end
29
+
30
+ private
31
+
32
+ # Forty a page, not a hundred: Jobber prices a query by its page size and refuses the
33
+ # wider one, and what an includes brings back is charged for on top.
34
+ def page
35
+ <<~GRAPHQL
36
+ query($after: String, $filter: JobFilterAttributes) {
37
+ jobs(first: 40, after: $after, filter: $filter) {
38
+ nodes { #{FIELDS} #{selections} }
39
+ pageInfo { hasNextPage endCursor }
40
+ }
41
+ }
42
+ GRAPHQL
43
+ end
44
+
45
+ def one
46
+ <<~GRAPHQL
47
+ query($id: EncodedId!) {
48
+ job(id: $id) { #{FIELDS} #{selections} }
49
+ }
50
+ GRAPHQL
51
+ end
52
+
53
+ def field = 'jobs'
54
+
55
+ def item(node) = Job.new node: node
56
+ end
57
+ end
@@ -0,0 +1,17 @@
1
+ module Jbr
2
+ # A client that reads from {Jbr.mock} instead of Jobber.
3
+ class Mock::Client < Client
4
+ # @return [Object, nil] the values the app asked for.
5
+ def id = @node[:id]
6
+
7
+ def first_name = @node[:first_name]
8
+
9
+ def last_name = @node[:last_name]
10
+
11
+ def company_name = @node[:company_name]
12
+
13
+ def email = @node[:email]
14
+
15
+ def phone = @node[:phone]
16
+ end
17
+ end
data/lib/jbr/mock/job.rb CHANGED
@@ -1,17 +1,29 @@
1
1
  module Jbr
2
2
  # A job that reads from {Jbr.mock} instead of Jobber.
3
3
  class Mock::Job < Job
4
- # @return [Mock::Job] itself, carrying the mocked IDs.
5
- def find(_)
6
- @id = Jbr.mock.job[:id]
7
- @quote_id = Jbr.mock.job[:quote_id]
4
+ # @return [Object, nil] the values the app asked for.
5
+ def id = @node[:id]
8
6
 
9
- self
10
- end
7
+ def title = @node[:title]
11
8
 
12
- # @return [Time, nil] the times the app asked for.
13
- def scheduled_at = Jbr.mock.job[:scheduled_at]
9
+ def instructions = @node[:instructions]
14
10
 
15
- def completed_at = Jbr.mock.job[:completed_at]
11
+ def status = @node[:status]
12
+
13
+ def quote_id = @node[:quote_id]
14
+
15
+ def total = @node[:total]
16
+
17
+ def quote_total = @node[:quote_total]
18
+
19
+ def created_at = @node[:created_at]
20
+
21
+ def client = Mock::Client.new(node: @node.fetch(:client, {}))
22
+
23
+ def property = Mock::Property.new(node: @node.fetch(:property, {}))
24
+
25
+ def scheduled_at = @node[:scheduled_at]
26
+
27
+ def completed_at = @node[:completed_at]
16
28
  end
17
29
  end
@@ -0,0 +1,24 @@
1
+ module Jbr
2
+ # The jobs an app under test asked {Jbr.mock} to answer with.
3
+ class Mock::Jobs < Jobs
4
+ # @return [Mock::Job] the one job the app named, whatever ID is asked for.
5
+ def find(_) = Mock::Job.new node: Jbr.mock.job
6
+
7
+ # @return [Enumerator<Mock::Job>] every job the app named.
8
+ def each(&) = mocked(Jbr.mock.jobs).each(&)
9
+
10
+ # @return [Enumerator<Mock::Job>] those it dated from now on, and any it left undated.
11
+ def upcoming = mocked Jbr.mock.jobs.reject { |job| started? job }
12
+
13
+ # @return [Enumerator<Mock::Job>] those it dated before now.
14
+ def past = mocked Jbr.mock.jobs.select { |job| started? job }
15
+
16
+ private
17
+
18
+ def mocked(jobs)
19
+ Enumerator.new { |yielder| jobs.each { |job| yielder << Mock::Job.new(node: job) } }
20
+ end
21
+
22
+ def started?(job) = job[:scheduled_at] ? job[:scheduled_at] <= Time.now : false
23
+ end
24
+ end
@@ -3,11 +3,11 @@ module Jbr
3
3
  class Mock::OAuth < OAuth
4
4
  # The mocked resources these credentials read and write.
5
5
  def invoices = Mock::Invoice.new(oauth: self)
6
- def jobs = Mock::Job.new(oauth: self)
6
+ def jobs = Mock::Jobs.new(oauth: self)
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
+ def visits = Mock::Visits.new(oauth: self)
11
11
 
12
12
  # Revoking a mocked token asks nobody.
13
13
  def delete; end
@@ -0,0 +1,15 @@
1
+ module Jbr
2
+ # A property that reads from {Jbr.mock} instead of Jobber.
3
+ class Mock::Property < Property
4
+ # @return [Object, nil] the values the app asked for.
5
+ def id = @node[:id]
6
+
7
+ def street = @node[:street]
8
+
9
+ def city = @node[:city]
10
+
11
+ def zip = @node[:zip]
12
+
13
+ def client = Mock::Client.new(node: @node.fetch(:client, {}))
14
+ end
15
+ end
@@ -1,13 +1,6 @@
1
1
  module Jbr
2
2
  # A visit that reads from {Jbr.mock} instead of Jobber.
3
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
4
  # @return [Object, nil] the values the app asked for.
12
5
  def id = @node[:id]
13
6
 
@@ -15,9 +8,9 @@ module Jbr
15
8
 
16
9
  def job_id = @node[:job_id]
17
10
 
18
- def client = @node.fetch :client, {}
11
+ def client = Mock::Client.new(node: @node.fetch(:client, {}))
19
12
 
20
- def property = @node.fetch :property, {}
13
+ def property = Mock::Property.new(node: @node.fetch(:property, {}))
21
14
 
22
15
  def all_day? = @node[:all_day]
23
16
 
@@ -0,0 +1,21 @@
1
+ module Jbr
2
+ # The visits an app under test asked {Jbr.mock} to answer with.
3
+ class Mock::Visits < Visits
4
+ # @return [Enumerator<Mock::Visit>] every visit the app named.
5
+ def each(&) = mocked(Jbr.mock.visits).each(&)
6
+
7
+ # @return [Enumerator<Mock::Visit>] those it dated from now on, and any it left undated.
8
+ def upcoming = mocked Jbr.mock.visits.reject { |visit| started? visit }
9
+
10
+ # @return [Enumerator<Mock::Visit>] those it dated before now.
11
+ def past = mocked Jbr.mock.visits.select { |visit| started? visit }
12
+
13
+ private
14
+
15
+ def mocked(visits)
16
+ Enumerator.new { |yielder| visits.each { |visit| yielder << Mock::Visit.new(node: visit) } }
17
+ end
18
+
19
+ def started?(visit) = visit[:starts_at] ? visit[:starts_at] <= Time.now : false
20
+ end
21
+ 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, :visits, :oauth_url, :oauth_error
5
+ attr_accessor :quote, :job, :invoice, :request, :visits, :jobs, :oauth_url, :oauth_error
6
6
  end
7
7
  end
data/lib/jbr/oauth.rb CHANGED
@@ -29,10 +29,10 @@ module Jbr
29
29
  def account = Account.new oauth: self
30
30
  def clients = Client.new oauth: self
31
31
  def invoices = Invoice.new oauth: self
32
- def jobs = Job.new oauth: self
32
+ def jobs = Jobs.new oauth: self
33
33
  def quotes = Quote.new oauth: self
34
34
  def requests = Request.new oauth: self
35
- def visits = Visit.new oauth: self
35
+ def visits = Visits.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.
@@ -0,0 +1,17 @@
1
+ module Jbr
2
+ # Extends a record that stands at a property: a visit, a job, anything Jobber files
3
+ # somewhere.
4
+ module Properted
5
+ # What to ask for where a record names the place the work happens at.
6
+ # @param client [Boolean] whether the place's own client comes back with it, which spares
7
+ # a second query for somebody Jobber already knows the place belongs to.
8
+ def self.selection(client: false)
9
+ [ 'property { id address {', Property::SELECTION, '}',
10
+ (Cliental::SELECTION if client), '}',
11
+ ].compact.join ' '
12
+ end
13
+
14
+ # @return [Property] where the work happens, and who Jobber holds the place for.
15
+ def property = Property.new node: @node.fetch('property', {})
16
+ end
17
+ end
data/lib/jbr/property.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  module Jbr
2
2
  # Where the work happens: one address on a client's file.
3
3
  class Property < Resource
4
+ include Cliental
5
+
4
6
  # The mutation that adds a property to a client already on file.
5
7
  CREATE = <<~GRAPHQL
6
8
  mutation propertyCreateMutation($clientId: EncodedId!, $input: PropertyCreateInput!) {
@@ -20,6 +22,19 @@ module Jbr
20
22
  # The address as Jobber answers it, asked for wherever a property is read.
21
23
  SELECTION = "#{FIELDS.keys.join ' '} coordinates { #{COORDINATES.join ' '} }"
22
24
 
25
+ # Where the place is, in the fields a caller passes to open one.
26
+ # @return [Hash] any of :street, :city, :state, :zip, :latitude and :longitude.
27
+ def address = Property.fields_from @node['address']
28
+
29
+ # @return [String, nil] the street the work happens on.
30
+ def street = address[:street]
31
+
32
+ # @return [String, nil] the town the work happens in.
33
+ def city = address[:city]
34
+
35
+ # @return [String, nil] the postal code the work happens in.
36
+ def zip = address[:zip]
37
+
23
38
  # The fields a match is made on. City and state are written but never matched:
24
39
  # Jobber holds whatever was typed, so "NC" and "North Carolina" -- or "Winston Salem"
25
40
  # and "Winston-Salem" -- would read as two homes. The ZIP already places the home.
data/lib/jbr/resource.rb CHANGED
@@ -1,12 +1,45 @@
1
1
  module Jbr
2
- # What every Jobber resource shares: the credentials it is read or written through.
2
+ # What every Jobber resource shares: the node Jobber answered with, the credentials it was
3
+ # read through, and the paging that every list of them arrives in.
3
4
  class Resource
4
- # @param oauth [OAuth] the credentials to reach Jobber with.
5
- def initialize(oauth:)
5
+ # @param oauth [OAuth] the credentials to reach Jobber with, where reaching it is needed.
6
+ # @param node [Hash] the record as Jobber answered it.
7
+ # @param includes [Hash] what a list was asked to bring back beside its records.
8
+ def initialize(oauth: nil, node: {}, includes: {})
6
9
  @oauth = oauth
10
+ @node = node
11
+ @id = node['id']
12
+ @includes = includes
7
13
  end
8
14
 
9
- # @return [String, nil] the Jobber ID, once the resource has been read or created.
15
+ # @return [String, nil] the Jobber ID, from the node it came in or once one was created.
10
16
  attr_reader :id
17
+
18
+ private
19
+
20
+ # @return [Time, nil] what Jobber answered under a key, as a time.
21
+ def time(key) = (Time.iso8601 @node[key] if @node[key])
22
+
23
+ # Every item a paged query answers, one at a time, a page read only once the one before
24
+ # it runs out. The filter is data: handed none, the query narrows nothing.
25
+ def walk(filter = nil)
26
+ Enumerator.new do |yielder|
27
+ after = nil
28
+ loop do
29
+ answered = @oauth.query(page, variables: { after: after, filter: filter }.compact)
30
+ current = answered.fetch field, {}
31
+ current.fetch('nodes', []).each { |node| yielder << item(node) }
32
+ break unless current.dig 'pageInfo', 'hasNextPage'
33
+
34
+ after = current.dig 'pageInfo', 'endCursor'
35
+ end
36
+ end
37
+ end
38
+
39
+ # The two halves of a schedule, split at the moment they are asked for rather than per
40
+ # page: read page by page the boundary would slide, and something could cross it unseen.
41
+ def from_now = { startAt: { after: Time.now.iso8601 } }
42
+
43
+ def until_now = { startAt: { before: Time.now.iso8601 } }
11
44
  end
12
45
  end
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.4.0'
4
+ VERSION = '3.0.0'
5
5
  end
data/lib/jbr/visit.rb CHANGED
@@ -1,43 +1,7 @@
1
1
  module Jbr
2
2
  # One stop at a property: when the work on a job is scheduled to happen.
3
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, email: :email }
6
-
7
- # The query that reads a page of visits starting after a moment, oldest first. Forty a
8
- # page, not a hundred: Jobber prices a query by its page size and refuses the wider one.
9
- UPCOMING = <<~GRAPHQL
10
- query($after: String, $from: ISO8601DateTime!) {
11
- visits(first: 40, after: $after, filter: { startAt: { after: $from } }) {
12
- nodes {
13
- id title startAt endAt allDay clientConfirmed
14
- job { id }
15
- client { #{CLIENT_FIELDS.keys.join ' '} #{Phone::SELECTION} }
16
- property { id address { #{Property::SELECTION} } }
17
- }
18
- pageInfo { hasNextPage endCursor }
19
- }
20
- }
21
- GRAPHQL
22
-
23
- # @param oauth [OAuth] the credentials to reach Jobber with.
24
- # @param node [Hash] the visit as Jobber answered it.
25
- def initialize(oauth:, node: {})
26
- super oauth: oauth
27
- @node = node
28
- end
29
-
30
- # The visits scheduled from now on, oldest first. Nothing is read until the enumerator
31
- # is walked, and a page is read only once the one before it runs out.
32
- # @return [Enumerator<Visit>] the account's upcoming visits.
33
- def upcoming
34
- Enumerator.new do |yielder|
35
- nodes.each { |node| yielder << self.class.new(oauth: @oauth, node: node) }
36
- end
37
- end
38
-
39
- # @return [String, nil] the Jobber ID of the visit.
40
- def id = @node['id']
4
+ include Cliental, Properted
41
5
 
42
6
  # @return [String, nil] what the visit is called.
43
7
  def title = @node['title']
@@ -51,46 +15,10 @@ module Jbr
51
15
  # @return [Boolean, nil] whether the client has confirmed the visit.
52
16
  def client_confirmed? = @node['clientConfirmed']
53
17
 
54
- # Who the work is for, in the fields a client is created with.
55
- # @return [Hash] any of :id, :first_name, :last_name, :phone and :email.
56
- def client
57
- fields = CLIENT_FIELDS.to_h { |jobber, ours| [ ours, @node.dig('client', jobber.to_s) ] }
58
- fields.merge(phone: Phone.from(@node.dig('client', 'phones'))).compact
59
- end
60
-
61
- # Where the work happens, in the fields {Property} carries, under the ID Jobber files
62
- # it by -- the shape {#client} answers in, so both read the same way.
63
- # @return [Hash] any of :id, :street, :city, :state, :zip, :latitude and :longitude.
64
- def property
65
- fields = Property.fields_from @node.dig('property', 'address')
66
- { id: @node.dig('property', 'id') }.merge fields
67
- end
68
-
69
18
  # @return [Time, nil] the visit start time
70
- def starts_at
71
- Time.iso8601(@node['startAt']) if @node['startAt']
72
- end
19
+ def starts_at = time 'startAt'
73
20
 
74
21
  # @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
22
+ def ends_at = time 'endAt'
95
23
  end
96
24
  end
data/lib/jbr/visits.rb ADDED
@@ -0,0 +1,38 @@
1
+ module Jbr
2
+ # The visits on a Jobber account, oldest first, walked a page at a time.
3
+ class Visits < Resource
4
+ include Enumerable, Includable
5
+
6
+ # What a visit answers with, before anything it was asked to bring back with it.
7
+ FIELDS = 'id title startAt endAt allDay clientConfirmed job { id }'
8
+
9
+ # Every visit on the account, past and future alike. Nothing is read until the walk
10
+ # starts, and a page is read only once the one before it runs out.
11
+ def each(&) = walk.each(&)
12
+
13
+ # @return [Enumerator<Visit>] the visits scheduled from now on.
14
+ def upcoming = walk from_now
15
+
16
+ # @return [Enumerator<Visit>] the visits that started before now.
17
+ def past = walk until_now
18
+
19
+ private
20
+
21
+ # Forty a page, not a hundred: Jobber prices a query by its page size and refuses the
22
+ # wider one, and what an includes brings back is charged for on top.
23
+ def page
24
+ <<~GRAPHQL
25
+ query($after: String, $filter: VisitFilterAttributes) {
26
+ visits(first: 40, after: $after, filter: $filter) {
27
+ nodes { #{FIELDS} #{selections} }
28
+ pageInfo { hasNextPage endCursor }
29
+ }
30
+ }
31
+ GRAPHQL
32
+ end
33
+
34
+ def field = 'visits'
35
+
36
+ def item(node) = Visit.new node: node
37
+ end
38
+ end
data/lib/jbr.rb CHANGED
@@ -9,28 +9,39 @@ require 'jbr/mock'
9
9
 
10
10
  require 'jbr/url'
11
11
  require 'jbr/error'
12
+ # Phone before Cliental, and Cliental before the records that include it: what each asks
13
+ # Jobber for about a client is built as they load.
14
+ require 'jbr/phone'
15
+ require 'jbr/cliental'
12
16
  require 'jbr/resource'
13
17
  require 'jbr/request'
14
18
  require 'jbr/oauth'
15
19
 
16
20
  require 'jbr/account'
17
- # Phone and Property come before Client and Visit: their queries read these as they load.
18
- require 'jbr/phone'
21
+ # Property comes before Client and Visit: their queries read its fields as they load.
19
22
  require 'jbr/property'
23
+ require 'jbr/properted'
24
+ require 'jbr/includable'
20
25
  require 'jbr/client'
21
26
  require 'jbr/invoice'
22
27
  require 'jbr/job'
28
+ require 'jbr/jobs'
23
29
  require 'jbr/quote'
24
30
  require 'jbr/visit'
31
+ require 'jbr/visits'
25
32
 
26
33
  require 'jbr/mock/oauth'
34
+ require 'jbr/mock/client'
35
+ require 'jbr/mock/property'
27
36
  require 'jbr/mock/quote'
28
37
  require 'jbr/mock/job'
38
+ require 'jbr/mock/jobs'
29
39
  require 'jbr/mock/invoice'
30
40
  require 'jbr/mock/request'
31
41
  require 'jbr/mock/account'
32
42
  require 'jbr/mock/url'
33
43
  require 'jbr/mock/visit'
44
+ require 'jbr/mock/visits'
34
45
 
35
46
  require 'jbr/mocking'
36
47
 
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.4.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -96,22 +96,30 @@ files:
96
96
  - lib/jbr.rb
97
97
  - lib/jbr/account.rb
98
98
  - lib/jbr/client.rb
99
+ - lib/jbr/cliental.rb
99
100
  - lib/jbr/error.rb
100
101
  - lib/jbr/event.rb
102
+ - lib/jbr/includable.rb
101
103
  - lib/jbr/invoice.rb
102
104
  - lib/jbr/job.rb
105
+ - lib/jbr/jobs.rb
103
106
  - lib/jbr/mock.rb
104
107
  - lib/jbr/mock/account.rb
108
+ - lib/jbr/mock/client.rb
105
109
  - lib/jbr/mock/invoice.rb
106
110
  - lib/jbr/mock/job.rb
111
+ - lib/jbr/mock/jobs.rb
107
112
  - lib/jbr/mock/oauth.rb
113
+ - lib/jbr/mock/property.rb
108
114
  - lib/jbr/mock/quote.rb
109
115
  - lib/jbr/mock/request.rb
110
116
  - lib/jbr/mock/url.rb
111
117
  - lib/jbr/mock/visit.rb
118
+ - lib/jbr/mock/visits.rb
112
119
  - lib/jbr/mocking.rb
113
120
  - lib/jbr/oauth.rb
114
121
  - lib/jbr/phone.rb
122
+ - lib/jbr/properted.rb
115
123
  - lib/jbr/property.rb
116
124
  - lib/jbr/quote.rb
117
125
  - lib/jbr/request.rb
@@ -119,6 +127,7 @@ files:
119
127
  - lib/jbr/url.rb
120
128
  - lib/jbr/version.rb
121
129
  - lib/jbr/visit.rb
130
+ - lib/jbr/visits.rb
122
131
  homepage: https://github.com/HouseAccountEng/jbr
123
132
  licenses:
124
133
  - MIT