jbr 2.4.0 → 3.1.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 +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +71 -15
- data/lib/graphql/client.rb +2 -2
- data/lib/jbr/client.rb +21 -4
- data/lib/jbr/cliental.rb +14 -0
- data/lib/jbr/includable.rb +27 -0
- data/lib/jbr/job.rb +26 -30
- data/lib/jbr/jobs.rb +57 -0
- data/lib/jbr/mock/client.rb +17 -0
- data/lib/jbr/mock/job.rb +21 -9
- data/lib/jbr/mock/jobs.rb +24 -0
- data/lib/jbr/mock/oauth.rb +2 -2
- data/lib/jbr/mock/property.rb +15 -0
- data/lib/jbr/mock/visit.rb +2 -9
- data/lib/jbr/mock/visits.rb +21 -0
- data/lib/jbr/mock.rb +1 -1
- data/lib/jbr/named.rb +9 -0
- data/lib/jbr/oauth.rb +2 -2
- data/lib/jbr/properted.rb +17 -0
- data/lib/jbr/property.rb +17 -2
- data/lib/jbr/resource.rb +38 -4
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr/visit.rb +3 -75
- data/lib/jbr/visits.rb +38 -0
- data/lib/jbr.rb +20 -2
- metadata +25 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97dff097405bb45eb1ac6048e9ce131d6905f19221e1e910ccff1f533ede76ed
|
|
4
|
+
data.tar.gz: 2cf46317599aa7146b73fa734441a0c995749692cea4565bcc6646b80fff478c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6fc99f2dbaa979073811af9382023b10acde8182e89ded303876876f92e54bcbce9f2ee732917003571823aa2b3127b8f960ad912f88ab53935e8041022b3f4a
|
|
7
|
+
data.tar.gz: 656ca93656a1702f226d9f8aa4ed12e7e0d31f01a1ac4cda426096ec7eea48e30e5b2977ccbc113a5dfa21fdb6084009b400caae836f23335a4bd28a4626511d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
+
## [3.1.0] - 2026-08-13
|
|
2
|
+
|
|
3
|
+
- [Fix] An answer Jobber left empty now reads as no answer rather than as an empty string.
|
|
4
|
+
`client.name` and `job.name` fall through a blank first name or a blank title instead of
|
|
5
|
+
handing one back, so a caller that validates presence is not handed `""` to store. A job
|
|
6
|
+
with no title still answers the ID it is filed under; a client with neither a first name
|
|
7
|
+
nor a company name answers nil
|
|
8
|
+
- [New] A visit answers `name` as a job does -- its title, or the ID Jobber files it under
|
|
9
|
+
where nobody titled it. Both read it from `Jbr::Named`, so a record Jobber lets go
|
|
10
|
+
untitled is named the same way wherever it appears
|
|
11
|
+
- [Fix] A blank address field does not come back from `property.address`, and is not sent
|
|
12
|
+
when a property is opened. Street, city, state and ZIP are absent rather than empty
|
|
13
|
+
- [Fix] A blank timestamp reads as no time. `Time.iso8601` raises on an empty string, so a
|
|
14
|
+
visit or job Jobber dated with one used to take the whole walk down with it
|
|
15
|
+
- [Change] Depend on Active Support, for `blank?`, `present?`, `presence` and
|
|
16
|
+
`compact_blank`. Two of its files are required, not the whole library: telling an empty
|
|
17
|
+
answer from a missing one was being done by hand, and being done inconsistently
|
|
18
|
+
|
|
19
|
+
## [3.0.0] - 2026-08-13
|
|
20
|
+
|
|
21
|
+
- [Breaking change] `oauth.visits` and `oauth.jobs` answer the whole collection rather than
|
|
22
|
+
one record: each is an Enumerable of every visit or job on the account, oldest first, read
|
|
23
|
+
a page at a time only as far as it is walked. `oauth.visits.upcoming` still answers the
|
|
24
|
+
ones dated from now on, `.past` answers the ones dated before now, and both split the
|
|
25
|
+
schedule at the moment they are asked rather than per page, so nothing crosses the
|
|
26
|
+
boundary unseen. `oauth.jobs.find(id)` is unchanged
|
|
27
|
+
- [Breaking change] A visit or a job answers `client` and `property` as objects rather than
|
|
28
|
+
hashes: `visit.client.phone` and `visit.property.street` where `visit.client[:phone]` and
|
|
29
|
+
`visit.property[:street]` used to read. A property answers `client` too, so the person a
|
|
30
|
+
place sits on the file of comes back with the place
|
|
31
|
+
- [Breaking change] Nothing nested is fetched unless it is asked for. Chain `includes` the
|
|
32
|
+
way Active Record does -- `oauth.visits.includes(:client, property: :client)` -- and only
|
|
33
|
+
what it names is added to the query. Jobber prices a query by what it brings back, so a
|
|
34
|
+
caller who wants the visit alone is no longer charged for the client and the place
|
|
35
|
+
- [New] Fetch jobs the way visits are fetched. A job carries `name`, `title`,
|
|
36
|
+
`instructions`, `status`, `total`, `quote_id`, `quote_total`, `created_at`, `scheduled_at`
|
|
37
|
+
and `completed_at`, and its client and property come back with `includes`. `name` is the
|
|
38
|
+
title, or the ID Jobber files the job under where nobody titled it, so it is never nil
|
|
39
|
+
- [New] A client answers `name`: their first name, or the name of the business where the
|
|
40
|
+
client is a business. Jobber files nobody without one or the other, so it is never nil.
|
|
41
|
+
`first_name`, `last_name`, `company_name`, `email` and `phone` read individually
|
|
42
|
+
- [New] Mock the jobs an account has with `Jbr.mock.jobs`, beside `Jbr.mock.visits`
|
|
43
|
+
|
|
1
44
|
## [2.4.0] - 2026-08-08
|
|
2
45
|
|
|
3
46
|
- [Breaking change] A visit answers `property` -- the ID Jobber files the place under, beside
|
data/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Jobber API Ruby client
|
|
2
2
|
|
|
3
|
-
A client for the Jobber GraphQL API. It needs
|
|
3
|
+
A client for the Jobber GraphQL API. It needs the standard library and two files of Active
|
|
4
|
+
Support, to tell a field Jobber answered empty from one it never answered at all.
|
|
4
5
|
|
|
5
6
|
## Available methods
|
|
6
7
|
|
|
@@ -63,7 +64,7 @@ quote.request_id # => 'Z2lkOi8vSm9iYmVyL'
|
|
|
63
64
|
|
|
64
65
|
### Jobs
|
|
65
66
|
|
|
66
|
-
Fetch a job from Jobber:
|
|
67
|
+
Fetch a job from Jobber by the ID it is filed under:
|
|
67
68
|
|
|
68
69
|
```ruby
|
|
69
70
|
job = oauth.jobs.find 'Njc5MTk5'
|
|
@@ -73,6 +74,25 @@ job.scheduled_at # => 2026-05-14 23:02:52
|
|
|
73
74
|
job.completed_at # => 2026-05-18 11:36:13
|
|
74
75
|
```
|
|
75
76
|
|
|
77
|
+
Or walk the account's jobs, oldest first. Jobber is asked for a page at a time, and only
|
|
78
|
+
once the page before it runs out, so `first` costs one request where `to_a` costs as many
|
|
79
|
+
as the account has pages:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
jobs = oauth.jobs # => an Enumerable of every job, nothing fetched yet
|
|
83
|
+
oauth.jobs.past # => an Enumerator of the ones dated before now
|
|
84
|
+
oauth.jobs.upcoming # => an Enumerator of the ones dated from now on
|
|
85
|
+
|
|
86
|
+
job = jobs.first
|
|
87
|
+
job.name # => 'Furnace tune-up', or the job's ID where nobody titled it. Never nil or empty
|
|
88
|
+
job.title # => 'Furnace tune-up'
|
|
89
|
+
job.instructions # => 'Ring the doorbell twice'
|
|
90
|
+
job.status # => 'requires_invoicing'
|
|
91
|
+
job.total # => 260.0
|
|
92
|
+
job.quote_total # => 240.0
|
|
93
|
+
job.created_at # => 2026-05-10 09:15:00
|
|
94
|
+
```
|
|
95
|
+
|
|
76
96
|
### Invoices
|
|
77
97
|
|
|
78
98
|
Fetch a non-draft invoice from Jobber:
|
|
@@ -88,27 +108,52 @@ invoice.completed_at # => 2026-05-22 14:32:53
|
|
|
88
108
|
|
|
89
109
|
### Visits
|
|
90
110
|
|
|
91
|
-
|
|
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:
|
|
111
|
+
Walk the account's visits, oldest first, the same way as its jobs:
|
|
94
112
|
|
|
95
113
|
```ruby
|
|
96
|
-
visits = oauth.visits
|
|
114
|
+
visits = oauth.visits # => an Enumerable of every visit, nothing fetched yet
|
|
115
|
+
oauth.visits.upcoming # => an Enumerator of the ones dated from now on
|
|
116
|
+
oauth.visits.past # => an Enumerator of the ones dated before now
|
|
117
|
+
|
|
97
118
|
visit = visits.first
|
|
98
119
|
visit.id # => 'Z2lkOi8vS'
|
|
120
|
+
visit.name # => 'Furnace tune-up', or the visit's ID where nobody titled it. Never nil or empty
|
|
99
121
|
visit.title # => 'Furnace tune-up'
|
|
100
122
|
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
123
|
visit.starts_at # => 2026-08-09 14:00:00
|
|
107
124
|
visit.ends_at # => 2026-08-09 16:00:00
|
|
108
125
|
visit.all_day? # => false
|
|
109
126
|
visit.client_confirmed? # => true
|
|
110
127
|
```
|
|
111
128
|
|
|
129
|
+
### Clients and properties
|
|
130
|
+
|
|
131
|
+
Jobber prices a query by what it brings back, so nothing nested comes back unless it is
|
|
132
|
+
asked for. Chain `includes` the way Active Record does, on visits or on jobs:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
visit = oauth.visits.includes(:client, property: :client).upcoming.first
|
|
136
|
+
|
|
137
|
+
visit.client.name # => 'Jane', or the business's name where the client is a business.
|
|
138
|
+
# Never an empty string: a blank first name falls through to the company
|
|
139
|
+
visit.client.first_name # => 'Jane'
|
|
140
|
+
visit.client.last_name # => 'Doe'
|
|
141
|
+
visit.client.company_name # => nil
|
|
142
|
+
visit.client.email # => 'jane@example.com'
|
|
143
|
+
visit.client.phone # => '5553335555', the reachable North American number, or nil
|
|
144
|
+
|
|
145
|
+
visit.property.id # => 'Z2lkOi8vS'
|
|
146
|
+
visit.property.street # => '1 Main St'
|
|
147
|
+
visit.property.city # => 'Raleigh'
|
|
148
|
+
visit.property.zip # => '27601'
|
|
149
|
+
visit.property.address # => { street: '1 Main St', city: 'Raleigh', state: 'NC',
|
|
150
|
+
# zip: '27601', latitude: 35.77, longitude: -78.63 }
|
|
151
|
+
visit.property.client.name # => whoever the place sits on the file of
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Ask for nothing and nothing arrives: `oauth.visits.first.client.name` is nil where the
|
|
155
|
+
query never named a client.
|
|
156
|
+
|
|
112
157
|
### Events
|
|
113
158
|
|
|
114
159
|
Parse the payload of a Jobber event webhook:
|
|
@@ -162,21 +207,32 @@ Jbr.mock.quote = { id: 'quote-01', request_id: 'request-01' }
|
|
|
162
207
|
|
|
163
208
|
### Jobs
|
|
164
209
|
|
|
165
|
-
Mock successfully fetching a job:
|
|
210
|
+
Mock successfully fetching a job by ID:
|
|
166
211
|
|
|
167
212
|
```ruby
|
|
168
213
|
Jbr.mock.job = { id: 'job-01', quote_id: 'quote-01', scheduled_at: Date.tomorrow.noon }
|
|
169
214
|
```
|
|
170
215
|
|
|
216
|
+
Mock the jobs the account has. The mock dates nothing it was handed: what answers to
|
|
217
|
+
`past` and to `upcoming` is whatever `scheduled_at` the app gave each one:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
Jbr.mock.jobs = [ { id: 'job-01', title: 'Furnace tune-up', status: 'archived',
|
|
221
|
+
total: 260.0, quote_total: 240.0, created_at: Date.yesterday.noon,
|
|
222
|
+
scheduled_at: Date.yesterday.noon, completed_at: Date.today.noon,
|
|
223
|
+
property: { id: 'property-01', street: '1 Main St',
|
|
224
|
+
client: { id: 'client-01', company_name: 'Acme Property Management' } } } ]
|
|
225
|
+
```
|
|
226
|
+
|
|
171
227
|
### Visits
|
|
172
228
|
|
|
173
|
-
Mock
|
|
229
|
+
Mock the visits the account has:
|
|
174
230
|
|
|
175
231
|
```ruby
|
|
176
232
|
Jbr.mock.visits = [ { id: 'visit-01', title: 'Furnace tune-up', job_id: 'job-01',
|
|
177
|
-
property: { id: 'property-01', street: '1 Main St'
|
|
233
|
+
property: { id: 'property-01', street: '1 Main St',
|
|
234
|
+
client: { id: 'client-01', first_name: 'Jane' } },
|
|
178
235
|
client: { id: 'client-01', first_name: 'Jane' },
|
|
179
|
-
address: { street: '1 Main St', city: 'Raleigh', state: 'NC', zip: '27601' },
|
|
180
236
|
starts_at: Date.tomorrow.noon, ends_at: Date.tomorrow.end_of_day,
|
|
181
237
|
all_day: false, client_confirmed: true } ]
|
|
182
238
|
```
|
data/lib/graphql/client.rb
CHANGED
|
@@ -22,8 +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
|
-
errors = body['errors']
|
|
26
|
-
raise Error, errors.map { |error| error['message'] }.join('; ')
|
|
25
|
+
errors = body['errors']
|
|
26
|
+
raise Error, errors.map { |error| error['message'] }.join('; ') if errors.present?
|
|
27
27
|
body.fetch('data')
|
|
28
28
|
end
|
|
29
29
|
|
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, nil] the person's first name, or the business's name. Never empty.
|
|
36
|
+
def name = first_name.presence || company_name.presence
|
|
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
|
|
@@ -70,12 +89,10 @@ module Jbr
|
|
|
70
89
|
address, email = @create_params[:address], @create_params[:email]
|
|
71
90
|
{ firstName: @create_params[:first_name],
|
|
72
91
|
lastName: @create_params[:last_name],
|
|
73
|
-
properties: ([ { address: Property.address_from(address) } ] if present?
|
|
92
|
+
properties: ([ { address: Property.address_from(address) } ] if address.present?),
|
|
74
93
|
phones: [ { number: @create_params[:phone], primary: true } ],
|
|
75
|
-
emails: ([ { address: email, primary: true } ] if present?
|
|
94
|
+
emails: ([ { address: email, primary: true } ] if email.present?),
|
|
76
95
|
}.compact
|
|
77
96
|
end
|
|
78
|
-
|
|
79
|
-
def present?(value) = !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
|
|
80
97
|
end
|
|
81
98
|
end
|
data/lib/jbr/cliental.rb
ADDED
|
@@ -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,33 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# Work a Jobber user accepted and scheduled.
|
|
3
3
|
class Job < Resource
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
include Cliental, Named, Properted
|
|
5
|
+
|
|
6
|
+
# @return [String, nil] what the job is called, where whoever opened it named it.
|
|
7
|
+
def title = @node['title']
|
|
8
|
+
|
|
9
|
+
# @return [String, nil] what the work is, in the words whoever opened the job wrote.
|
|
10
|
+
def instructions = @node['instructions']
|
|
11
|
+
|
|
12
|
+
# @return [String, nil] where Jobber files the job in its own workflow.
|
|
13
|
+
def status = @node['jobStatus']
|
|
10
14
|
|
|
11
15
|
# @return [String, nil] the ID of the quote the job was won with.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# @
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
16
|
+
def quote_id = @node.dig 'quote', 'id'
|
|
17
|
+
|
|
18
|
+
# @return [Float, nil] what the job comes to.
|
|
19
|
+
def total = @node['total']
|
|
20
|
+
|
|
21
|
+
# @return [Float, nil] what the quote the job was won with came to.
|
|
22
|
+
def quote_total = @node.dig 'quote', 'amounts', 'total'
|
|
23
|
+
|
|
24
|
+
# @return [Time, nil] the job opening time
|
|
25
|
+
def created_at = time 'createdAt'
|
|
26
|
+
|
|
27
|
+
# @return [Time, nil] the job scheduled time
|
|
28
|
+
def scheduled_at = time 'startAt'
|
|
29
|
+
|
|
30
|
+
# @return [Time, nil] the job completed time
|
|
31
|
+
def completed_at = time 'completedAt'
|
|
36
32
|
end
|
|
37
33
|
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 [
|
|
5
|
-
def
|
|
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
|
-
|
|
10
|
-
end
|
|
7
|
+
def title = @node[:title]
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
def scheduled_at = Jbr.mock.job[:scheduled_at]
|
|
9
|
+
def instructions = @node[:instructions]
|
|
14
10
|
|
|
15
|
-
def
|
|
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
|
data/lib/jbr/mock/oauth.rb
CHANGED
|
@@ -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::
|
|
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::
|
|
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
|
data/lib/jbr/mock/visit.rb
CHANGED
|
@@ -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
|
|
11
|
+
def client = Mock::Client.new(node: @node.fetch(:client, {}))
|
|
19
12
|
|
|
20
|
-
def property = @node.fetch
|
|
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/named.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# Extends a record Jobber lets go untitled: a visit, a job, anything somebody may have
|
|
3
|
+
# opened without ever typing a name for it.
|
|
4
|
+
module Named
|
|
5
|
+
# Untitled happens often enough, and something has to stand in for it on a list.
|
|
6
|
+
# @return [String] the title, or the ID Jobber files the record under. Never empty.
|
|
7
|
+
def name = title.presence || id
|
|
8
|
+
end
|
|
9
|
+
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 =
|
|
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 =
|
|
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.
|
|
@@ -29,7 +44,7 @@ module Jbr
|
|
|
29
44
|
# @param fields [Hash] any of :street, :city, :state and :zip.
|
|
30
45
|
# @return [Hash] the address, without the fields the caller left out.
|
|
31
46
|
def self.address_from(fields = {})
|
|
32
|
-
FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.
|
|
47
|
+
FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.compact_blank
|
|
33
48
|
end
|
|
34
49
|
|
|
35
50
|
# The fields a caller reads, from the address as Jobber holds it.
|
|
@@ -40,7 +55,7 @@ module Jbr
|
|
|
40
55
|
address ||= {}
|
|
41
56
|
coordinates = address['coordinates'] || {}
|
|
42
57
|
FIELDS.to_h { |jobber, ours| [ ours, address[jobber.to_s] ] }.
|
|
43
|
-
merge(COORDINATES.to_h { |ours| [ ours, coordinates[ours.to_s] ] }).
|
|
58
|
+
merge(COORDINATES.to_h { |ours| [ ours, coordinates[ours.to_s] ] }).compact_blank
|
|
44
59
|
end
|
|
45
60
|
|
|
46
61
|
# Reach the property at an address, adding one when none of the client's matches.
|
data/lib/jbr/resource.rb
CHANGED
|
@@ -1,12 +1,46 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
# What every Jobber resource shares: the
|
|
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
|
-
|
|
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,
|
|
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. An empty answer is no
|
|
21
|
+
# answer: Time.iso8601 raises on one, where nothing at all it simply has none of.
|
|
22
|
+
def time(key) = (Time.iso8601 @node[key] if @node[key].present?)
|
|
23
|
+
|
|
24
|
+
# Every item a paged query answers, one at a time, a page read only once the one before
|
|
25
|
+
# it runs out. The filter is data: handed none, the query narrows nothing.
|
|
26
|
+
def walk(filter = nil)
|
|
27
|
+
Enumerator.new do |yielder|
|
|
28
|
+
after = nil
|
|
29
|
+
loop do
|
|
30
|
+
answered = @oauth.query(page, variables: { after: after, filter: filter }.compact)
|
|
31
|
+
current = answered.fetch field, {}
|
|
32
|
+
current.fetch('nodes', []).each { |node| yielder << item(node) }
|
|
33
|
+
break unless current.dig 'pageInfo', 'hasNextPage'
|
|
34
|
+
|
|
35
|
+
after = current.dig 'pageInfo', 'endCursor'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The two halves of a schedule, split at the moment they are asked for rather than per
|
|
41
|
+
# page: read page by page the boundary would slide, and something could cross it unseen.
|
|
42
|
+
def from_now = { startAt: { after: Time.now.iso8601 } }
|
|
43
|
+
|
|
44
|
+
def until_now = { startAt: { before: Time.now.iso8601 } }
|
|
11
45
|
end
|
|
12
46
|
end
|
data/lib/jbr/version.rb
CHANGED
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
|
-
|
|
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, Named, 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
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
require 'json'
|
|
2
2
|
require 'net/http'
|
|
3
3
|
|
|
4
|
+
# Only the two Active Support files whose methods are used, rather than the whole of it:
|
|
5
|
+
# Jobber answers a field it holds nothing for with an empty string as readily as with null,
|
|
6
|
+
# and a caller who validates presence needs those to arrive as the same nothing.
|
|
7
|
+
require 'active_support/core_ext/object/blank'
|
|
8
|
+
require 'active_support/core_ext/enumerable'
|
|
9
|
+
|
|
4
10
|
require 'graphql/error'
|
|
5
11
|
require 'graphql/unauthorized'
|
|
6
12
|
require 'graphql/client'
|
|
@@ -9,28 +15,40 @@ require 'jbr/mock'
|
|
|
9
15
|
|
|
10
16
|
require 'jbr/url'
|
|
11
17
|
require 'jbr/error'
|
|
18
|
+
# Phone before Cliental, and Cliental before the records that include it: what each asks
|
|
19
|
+
# Jobber for about a client is built as they load.
|
|
20
|
+
require 'jbr/phone'
|
|
21
|
+
require 'jbr/cliental'
|
|
22
|
+
require 'jbr/named'
|
|
12
23
|
require 'jbr/resource'
|
|
13
24
|
require 'jbr/request'
|
|
14
25
|
require 'jbr/oauth'
|
|
15
26
|
|
|
16
27
|
require 'jbr/account'
|
|
17
|
-
#
|
|
18
|
-
require 'jbr/phone'
|
|
28
|
+
# Property comes before Client and Visit: their queries read its fields as they load.
|
|
19
29
|
require 'jbr/property'
|
|
30
|
+
require 'jbr/properted'
|
|
31
|
+
require 'jbr/includable'
|
|
20
32
|
require 'jbr/client'
|
|
21
33
|
require 'jbr/invoice'
|
|
22
34
|
require 'jbr/job'
|
|
35
|
+
require 'jbr/jobs'
|
|
23
36
|
require 'jbr/quote'
|
|
24
37
|
require 'jbr/visit'
|
|
38
|
+
require 'jbr/visits'
|
|
25
39
|
|
|
26
40
|
require 'jbr/mock/oauth'
|
|
41
|
+
require 'jbr/mock/client'
|
|
42
|
+
require 'jbr/mock/property'
|
|
27
43
|
require 'jbr/mock/quote'
|
|
28
44
|
require 'jbr/mock/job'
|
|
45
|
+
require 'jbr/mock/jobs'
|
|
29
46
|
require 'jbr/mock/invoice'
|
|
30
47
|
require 'jbr/mock/request'
|
|
31
48
|
require 'jbr/mock/account'
|
|
32
49
|
require 'jbr/mock/url'
|
|
33
50
|
require 'jbr/mock/visit'
|
|
51
|
+
require 'jbr/mock/visits'
|
|
34
52
|
|
|
35
53
|
require 'jbr/mocking'
|
|
36
54
|
|
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:
|
|
4
|
+
version: 3.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: minitest
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -96,22 +110,31 @@ files:
|
|
|
96
110
|
- lib/jbr.rb
|
|
97
111
|
- lib/jbr/account.rb
|
|
98
112
|
- lib/jbr/client.rb
|
|
113
|
+
- lib/jbr/cliental.rb
|
|
99
114
|
- lib/jbr/error.rb
|
|
100
115
|
- lib/jbr/event.rb
|
|
116
|
+
- lib/jbr/includable.rb
|
|
101
117
|
- lib/jbr/invoice.rb
|
|
102
118
|
- lib/jbr/job.rb
|
|
119
|
+
- lib/jbr/jobs.rb
|
|
103
120
|
- lib/jbr/mock.rb
|
|
104
121
|
- lib/jbr/mock/account.rb
|
|
122
|
+
- lib/jbr/mock/client.rb
|
|
105
123
|
- lib/jbr/mock/invoice.rb
|
|
106
124
|
- lib/jbr/mock/job.rb
|
|
125
|
+
- lib/jbr/mock/jobs.rb
|
|
107
126
|
- lib/jbr/mock/oauth.rb
|
|
127
|
+
- lib/jbr/mock/property.rb
|
|
108
128
|
- lib/jbr/mock/quote.rb
|
|
109
129
|
- lib/jbr/mock/request.rb
|
|
110
130
|
- lib/jbr/mock/url.rb
|
|
111
131
|
- lib/jbr/mock/visit.rb
|
|
132
|
+
- lib/jbr/mock/visits.rb
|
|
112
133
|
- lib/jbr/mocking.rb
|
|
134
|
+
- lib/jbr/named.rb
|
|
113
135
|
- lib/jbr/oauth.rb
|
|
114
136
|
- lib/jbr/phone.rb
|
|
137
|
+
- lib/jbr/properted.rb
|
|
115
138
|
- lib/jbr/property.rb
|
|
116
139
|
- lib/jbr/quote.rb
|
|
117
140
|
- lib/jbr/request.rb
|
|
@@ -119,6 +142,7 @@ files:
|
|
|
119
142
|
- lib/jbr/url.rb
|
|
120
143
|
- lib/jbr/version.rb
|
|
121
144
|
- lib/jbr/visit.rb
|
|
145
|
+
- lib/jbr/visits.rb
|
|
122
146
|
homepage: https://github.com/HouseAccountEng/jbr
|
|
123
147
|
licenses:
|
|
124
148
|
- MIT
|