jbr 3.13.0 → 5.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 +4 -4
- data/.rubocop.yml +5 -0
- data/CHANGELOG.md +139 -0
- data/README.md +302 -167
- data/lib/graphql/client.rb +2 -6
- data/lib/graphql/error.rb +10 -0
- data/lib/graphql/throttled.rb +0 -9
- data/lib/jbr/account.rb +48 -15
- data/lib/jbr/authorizing.rb +43 -0
- data/lib/jbr/booking.rb +56 -0
- data/lib/jbr/collection.rb +7 -0
- data/lib/jbr/collections/customers.rb +54 -0
- data/lib/jbr/collections/invoices.rb +18 -0
- data/lib/jbr/{jobs.rb → collections/jobs.rb} +8 -8
- data/lib/jbr/collections/leads.rb +30 -0
- data/lib/jbr/collections/locations.rb +47 -0
- data/lib/jbr/collections/quotes.rb +18 -0
- data/lib/jbr/collections/technicians.rb +17 -0
- data/lib/jbr/collections/visits.rb +61 -0
- data/lib/jbr/error.rb +1 -1
- data/lib/jbr/errors/throttled.rb +6 -0
- data/lib/jbr/includable.rb +10 -8
- data/lib/jbr/listable.rb +17 -35
- data/lib/jbr/logger.rb +13 -0
- data/lib/jbr/mock/account.rb +28 -7
- data/lib/jbr/mock/invoice.rb +7 -11
- data/lib/jbr/mock/invoices.rb +7 -0
- data/lib/jbr/mock/jobs.rb +10 -6
- data/lib/jbr/mock/leads.rb +7 -0
- data/lib/jbr/mock/quote.rb +3 -8
- data/lib/jbr/mock/quotes.rb +7 -0
- data/lib/jbr/mock/technicians.rb +12 -0
- data/lib/jbr/mock/visit.rb +9 -16
- data/lib/jbr/mock/visits.rb +39 -6
- data/lib/jbr/mock.rb +17 -4
- data/lib/jbr/phone.rb +5 -8
- data/lib/jbr/querying.rb +68 -0
- data/lib/jbr/reader.rb +9 -0
- data/lib/jbr/reading.rb +15 -0
- data/lib/jbr/refreshing.rb +0 -8
- data/lib/jbr/resources/customer.rb +23 -0
- data/lib/jbr/resources/invoice.rb +29 -0
- data/lib/jbr/resources/job.rb +20 -0
- data/lib/jbr/resources/lead.rb +7 -0
- data/lib/jbr/resources/line.rb +15 -0
- data/lib/jbr/resources/location.rb +31 -0
- data/lib/jbr/resources/quote.rb +15 -0
- data/lib/jbr/resources/technician.rb +19 -0
- data/lib/jbr/resources/visit.rb +30 -0
- data/lib/jbr/scheduled.rb +36 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr.rb +45 -45
- metadata +46 -28
- data/lib/jbr/client.rb +0 -98
- data/lib/jbr/cliental.rb +0 -14
- data/lib/jbr/invoice.rb +0 -43
- data/lib/jbr/itemized.rb +0 -9
- data/lib/jbr/job.rb +0 -40
- data/lib/jbr/line_item.rb +0 -44
- data/lib/jbr/mock/client.rb +0 -17
- data/lib/jbr/mock/job.rb +0 -31
- data/lib/jbr/mock/line_item.rb +0 -16
- data/lib/jbr/mock/oauth.rb +0 -22
- data/lib/jbr/mock/property.rb +0 -19
- data/lib/jbr/mock/request.rb +0 -12
- data/lib/jbr/mock/url.rb +0 -9
- data/lib/jbr/mocking.rb +0 -30
- data/lib/jbr/named.rb +0 -9
- data/lib/jbr/oauth.rb +0 -94
- data/lib/jbr/properted.rb +0 -17
- data/lib/jbr/property.rb +0 -93
- data/lib/jbr/quote.rb +0 -25
- data/lib/jbr/request.rb +0 -37
- data/lib/jbr/resource.rb +0 -26
- data/lib/jbr/retriable.rb +0 -20
- data/lib/jbr/url.rb +0 -16
- data/lib/jbr/visit.rb +0 -24
- data/lib/jbr/visits.rb +0 -36
- /data/lib/jbr/{refused.rb → errors/refused.rb} +0 -0
data/lib/jbr/listable.rb
CHANGED
|
@@ -3,11 +3,9 @@ module Jbr
|
|
|
3
3
|
# a query by the page it asks for and by what every row of that page carries, so a list is
|
|
4
4
|
# read either as records or as the IDs alone, each with a page sized to what it carries.
|
|
5
5
|
module Listable
|
|
6
|
-
include Enumerable
|
|
7
|
-
|
|
8
6
|
# Records a page, not forty and not a hundred: what an includes brings back is charged for
|
|
9
|
-
# on top of every row of it, so a page of jobs carrying their lines, their
|
|
10
|
-
#
|
|
7
|
+
# on top of every row of it, so a page of jobs carrying their lines, their location and its
|
|
8
|
+
# customer priced past what a bucket holds. Half the page costs half the query and loses
|
|
11
9
|
# nothing, since a walk simply reads more pages.
|
|
12
10
|
PAGE = 20
|
|
13
11
|
|
|
@@ -19,58 +17,42 @@ module Jbr
|
|
|
19
17
|
# starts, and a page is read only once the one before it runs out.
|
|
20
18
|
def each(&) = walk(page).each(&)
|
|
21
19
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# @
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# @param within [ActiveSupport::Duration, Numeric, nil] how far back to look, or nil for
|
|
31
|
-
# as far back as the account goes.
|
|
32
|
-
# @return [Listable] the same list, narrowed to what started before now.
|
|
33
|
-
def past(within = nil)
|
|
34
|
-
now = Time.now
|
|
35
|
-
narrowed before: now, after: (now - within if within)
|
|
36
|
-
end
|
|
20
|
+
# The two halves of a schedule split at one moment rather than per page: read page by page
|
|
21
|
+
# the boundary would slide, and something could cross it unseen.
|
|
22
|
+
# @param from [Time, nil] the moment the window opens, or nothing for as far back as it goes.
|
|
23
|
+
# @param to [Time, nil] the moment the window closes, or nothing for as far ahead as it goes.
|
|
24
|
+
# @return [Collection] the same list, narrowed to what starts between the two.
|
|
25
|
+
def between(from, to) = narrowed(startAt: { after: from&.iso8601, before: to&.iso8601 }.compact)
|
|
37
26
|
|
|
38
27
|
# The ID Jobber files each record under, and nothing else about it: the cheapest question
|
|
39
28
|
# an account can be walked with, and the one to ask where every record is then read on its
|
|
40
|
-
# own through
|
|
29
|
+
# own through `find`.
|
|
41
30
|
# @return [Array<String>] every ID in the list, every page of them read.
|
|
42
31
|
def ids = walk(ids_page).map(&:id)
|
|
43
32
|
|
|
44
33
|
private
|
|
45
34
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
bounds = { after: after&.iso8601, before: before&.iso8601 }.compact
|
|
51
|
-
self.class.new oauth: @oauth, includes: @includes, filter: { startAt: bounds }
|
|
35
|
+
# Every narrowing lands in the one filter Jobber takes, so asking for a week and asking for
|
|
36
|
+
# a technician compose whichever way round a caller writes them.
|
|
37
|
+
def narrowed(**more)
|
|
38
|
+
self.class.new account: @account, includes: @includes, filter: @filter.to_h.merge(more)
|
|
52
39
|
end
|
|
53
40
|
|
|
54
|
-
# Whether a moment falls in the stretch of the schedule the list was narrowed to, for
|
|
55
|
-
# anything answering one without asking Jobber. A record with no moment at all is in an
|
|
56
|
-
# open-ended upcoming list, since nothing has started it, and in no window, since a window
|
|
57
|
-
# is a stretch it would have to have fallen in.
|
|
58
41
|
def scheduled?(at)
|
|
59
|
-
|
|
42
|
+
bounds = @filter&.dig :startAt
|
|
43
|
+
return true unless bounds
|
|
60
44
|
|
|
61
|
-
after, before =
|
|
45
|
+
after, before = bounds.values_at :after, :before
|
|
62
46
|
return before.nil? unless at
|
|
63
47
|
|
|
64
48
|
(after.nil? || at >= Time.iso8601(after)) && (before.nil? || at <= Time.iso8601(before))
|
|
65
49
|
end
|
|
66
50
|
|
|
67
|
-
# Every record a paged query answers, one at a time, a page read only once the one before
|
|
68
|
-
# it runs out. The filter is data: narrowed to nothing, the query narrows nothing.
|
|
69
51
|
def walk(statement)
|
|
70
52
|
Enumerator.new do |yielder|
|
|
71
53
|
after = nil
|
|
72
54
|
loop do
|
|
73
|
-
answered = @
|
|
55
|
+
answered = @account.query statement, variables: { after: after, filter: @filter }.compact
|
|
74
56
|
current = answered.fetch field, {}
|
|
75
57
|
current.fetch('nodes', []).each { |node| yielder << item(node) }
|
|
76
58
|
break unless current.dig 'pageInfo', 'hasNextPage'
|
data/lib/jbr/logger.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require 'logger'
|
|
2
|
+
|
|
3
|
+
module Jbr
|
|
4
|
+
class << self
|
|
5
|
+
# Where this gem says what it could not do, for the things it carries on from rather than
|
|
6
|
+
# raises. An app with a log of its own points this at it.
|
|
7
|
+
# @return [Logger] somewhere to write, standard error until an app names somewhere better.
|
|
8
|
+
def logger = @logger ||= Logger.new($stderr)
|
|
9
|
+
|
|
10
|
+
# @return [Logger] where to write.
|
|
11
|
+
attr_writer :logger
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/jbr/mock/account.rb
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
#
|
|
2
|
+
# Credentials that answer from {Jbr.mock} instead of Jobber.
|
|
3
3
|
class Mock::Account < Account
|
|
4
|
-
# @return [
|
|
5
|
-
|
|
4
|
+
# @return [Company::Business] business the app named, under `account-01` where it named
|
|
5
|
+
# no ID.
|
|
6
|
+
def business = Company::Business.new node: { id: 'account-01' }.merge(Jbr.mock.business.to_h)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
# @return [Mock::Jobs] jobs the app listed.
|
|
9
|
+
def jobs = Mock::Jobs.new account: self
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
# @return [Mock::Visits] visits the app listed.
|
|
12
|
+
def visits = Mock::Visits.new account: self
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
# @return [Mock::Technicians] technicians the app listed.
|
|
15
|
+
def technicians = Mock::Technicians.new account: self
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
# @return [Mock::Quotes] the one quote the app named.
|
|
18
|
+
def quotes = Mock::Quotes.new
|
|
19
|
+
|
|
20
|
+
# @return [Mock::Leads] the one lead the app named.
|
|
21
|
+
def leads = Mock::Leads.new
|
|
22
|
+
|
|
23
|
+
# @return [Mock::Invoices] the one invoice the app named.
|
|
24
|
+
def invoices = Mock::Invoices.new
|
|
25
|
+
|
|
26
|
+
# Revoking a mocked token asks nobody.
|
|
27
|
+
def delete; end
|
|
28
|
+
|
|
29
|
+
# @return [Hash] canned credentials, unless the app asked for a refusal.
|
|
30
|
+
def self.post(_)
|
|
31
|
+
raise Error, Jbr.mock.oauth_error if Jbr.mock.oauth_error
|
|
32
|
+
|
|
33
|
+
{ access_token: 'mock-token', refresh_token: 'mock-token', expires_at: Time.now + 3600 }
|
|
34
|
+
end
|
|
14
35
|
end
|
|
15
36
|
end
|
data/lib/jbr/mock/invoice.rb
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
# An invoice
|
|
2
|
+
# An invoice an app under test named, keyed by the readers' names rather than Jobber's.
|
|
3
3
|
class Mock::Invoice < Invoice
|
|
4
|
-
#
|
|
5
|
-
def
|
|
6
|
-
@id = Jbr.mock.invoice[:id]
|
|
7
|
-
@job_id = Jbr.mock.invoice[:job_id]
|
|
8
|
-
@total = Jbr.mock.invoice[:total]
|
|
4
|
+
# The mock spells every key as the reader is named.
|
|
5
|
+
def self.keys = {}
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
# @return [Company::Job, nil] job the app named beside the invoice.
|
|
8
|
+
def job = record Company::Job, :job
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
def issued_at = Jbr.mock.invoice[:issued_at]
|
|
10
|
+
private
|
|
15
11
|
|
|
16
|
-
def completed_at =
|
|
12
|
+
def completed_at = time :completed_at
|
|
17
13
|
end
|
|
18
14
|
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The one invoice an app under test asked {Jbr.mock} to answer with.
|
|
3
|
+
class Mock::Invoices
|
|
4
|
+
# @return [Mock::Invoice, nil] invoice the app named, whatever ID is asked for.
|
|
5
|
+
def find(_id) = (Mock::Invoice.new node: Jbr.mock.invoice if Jbr.mock.invoice)
|
|
6
|
+
end
|
|
7
|
+
end
|
data/lib/jbr/mock/jobs.rb
CHANGED
|
@@ -3,20 +3,24 @@ module Jbr
|
|
|
3
3
|
# narrowing a list, and reading it as records or as IDs, is the same code a real one runs.
|
|
4
4
|
class Mock::Jobs < Jobs
|
|
5
5
|
# The job filed under that ID where the app listed one, and otherwise the single job it
|
|
6
|
-
# named
|
|
7
|
-
# @
|
|
8
|
-
|
|
6
|
+
# named, which is every app that mocks a lookup without mocking a list.
|
|
7
|
+
# @param id [String] ID the app filed the job under.
|
|
8
|
+
# @return [Company::Job, nil] job asked for, nil where the app named none.
|
|
9
|
+
def find(id)
|
|
10
|
+
node = listed(id) || Jbr.mock.job
|
|
11
|
+
Company::Job.new node: node if node
|
|
12
|
+
end
|
|
9
13
|
|
|
10
14
|
private
|
|
11
15
|
|
|
12
16
|
def walk(_statement)
|
|
13
|
-
Enumerator.new
|
|
17
|
+
Enumerator.new do |yielder|
|
|
18
|
+
selected.each { |node| yielder << Company::Job.new(node: node) }
|
|
19
|
+
end
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
def selected = Jbr.mock.jobs.select { |job| scheduled? job[:scheduled_at] }
|
|
17
23
|
|
|
18
|
-
# Only a real list is looked through: an app that mocked the list as something raising
|
|
19
|
-
# was mocking the walk failing, and a lookup is a question of its own.
|
|
20
24
|
def listed(id) = (Jbr.mock.jobs.find { |job| job[:id] == id } if Jbr.mock.jobs.is_a? Array)
|
|
21
25
|
end
|
|
22
26
|
end
|
data/lib/jbr/mock/quote.rb
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
# A quote
|
|
2
|
+
# A quote an app under test named, its lead under the mock's own key rather than Jobber's.
|
|
3
3
|
class Mock::Quote < Quote
|
|
4
|
-
# @return [
|
|
5
|
-
def
|
|
6
|
-
@id = Jbr.mock.quote[:id]
|
|
7
|
-
@request_id = Jbr.mock.quote[:request_id]
|
|
8
|
-
|
|
9
|
-
self
|
|
10
|
-
end
|
|
4
|
+
# @return [Company::Lead, nil] lead the app named beside the quote.
|
|
5
|
+
def lead = record Company::Lead, :lead
|
|
11
6
|
end
|
|
12
7
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The technicians an app under test asked {Jbr.mock} to answer with.
|
|
3
|
+
class Mock::Technicians < Technicians
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def walk(_statement)
|
|
7
|
+
Enumerator.new do |yielder|
|
|
8
|
+
Jbr.mock.technicians.to_a.each { |node| yielder << Company::Technician.new(node: node) }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/jbr/mock/visit.rb
CHANGED
|
@@ -1,23 +1,16 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
# A visit
|
|
2
|
+
# A visit an app under test listed, keyed by the vocabulary's names rather than Jobber's.
|
|
3
3
|
class Mock::Visit < Visit
|
|
4
|
-
#
|
|
5
|
-
def
|
|
4
|
+
# The mock spells every key as the reader is named.
|
|
5
|
+
def self.keys = {}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
# @return [Array<Company::Technician>] whoever the app said the visit is booked for.
|
|
8
|
+
def technicians = records Company::Technician, :technicians
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
# @return [Company::Lead, nil] lead the app said the visit was booked against.
|
|
11
|
+
def lead = record Company::Lead, :lead
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def property = Mock::Property.new(node: @node.fetch(:property, {}))
|
|
14
|
-
|
|
15
|
-
def all_day? = @node[:all_day]
|
|
16
|
-
|
|
17
|
-
def client_confirmed? = @node[:client_confirmed]
|
|
18
|
-
|
|
19
|
-
def starts_at = @node[:starts_at]
|
|
20
|
-
|
|
21
|
-
def ends_at = @node[:ends_at]
|
|
13
|
+
# @return [Company::Location, nil] where the app said the visit is.
|
|
14
|
+
def location = record Company::Location, :location
|
|
22
15
|
end
|
|
23
16
|
end
|
data/lib/jbr/mock/visits.rb
CHANGED
|
@@ -1,18 +1,51 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# The visits an app under test asked {Jbr.mock} to answer with. Only the walk is mocked:
|
|
3
|
-
# narrowing a list, and reading it as records or as IDs, is the same code a real one runs
|
|
3
|
+
# narrowing a list, and reading it as records or as IDs, is the same code a real one runs,
|
|
4
|
+
# so a list nothing narrowed refuses here exactly as Jobber refuses it.
|
|
4
5
|
class Mock::Visits < Visits
|
|
5
|
-
#
|
|
6
|
-
|
|
6
|
+
# Jobber will not book a moment that names no zone, so neither will this: an app whose
|
|
7
|
+
# tests pass a bare Time would otherwise only learn of it in production.
|
|
8
|
+
# @return [Mock::Visit] stop the app listed as its lead, booked for the hour asked for.
|
|
9
|
+
def create(starts_at:, ends_at:, technicians:, **)
|
|
10
|
+
zone_of starts_at
|
|
11
|
+
Mock::Visit.new node: { id: 'visit-01', starts_at: starts_at, ends_at: ends_at,
|
|
12
|
+
technicians: technicians.map { |each| { id: each.id } },
|
|
13
|
+
lead: Jbr.mock.lead.to_h, }
|
|
14
|
+
end
|
|
7
15
|
|
|
8
16
|
private
|
|
9
17
|
|
|
10
18
|
def walk(_statement)
|
|
11
|
-
Enumerator.new
|
|
19
|
+
Enumerator.new do |yielder|
|
|
20
|
+
selected.each { |node| yielder << Mock::Visit.new(node: node) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def selected
|
|
25
|
+
Jbr.mock.visits.select do |visit|
|
|
26
|
+
scheduled?(visit[:starts_at]) && assigned?(visit) && kind?(visit)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def scheduled?(at)
|
|
31
|
+
window = @filter&.dig :occursWithin
|
|
32
|
+
return true unless window && at
|
|
33
|
+
|
|
34
|
+
at >= Time.iso8601(window[:startAt]) && at <= Time.iso8601(window[:endAt])
|
|
12
35
|
end
|
|
13
36
|
|
|
14
|
-
def
|
|
37
|
+
def assigned?(visit)
|
|
38
|
+
wanted = @filter&.dig :assignedTo
|
|
39
|
+
return true unless wanted
|
|
40
|
+
|
|
41
|
+
Array(visit[:technicians]).any? { |technician| wanted.include? technician[:id] }
|
|
42
|
+
end
|
|
15
43
|
|
|
16
|
-
def
|
|
44
|
+
def kind?(visit)
|
|
45
|
+
kind = @filter&.dig :scheduleItemType
|
|
46
|
+
return true unless kind
|
|
47
|
+
|
|
48
|
+
kind == 'VISIT' ? !visit[:job].nil? : !visit[:lead].nil?
|
|
49
|
+
end
|
|
17
50
|
end
|
|
18
51
|
end
|
data/lib/jbr/mock.rb
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
# What an app under test wants Jobber to answer.
|
|
2
|
+
# What an app under test wants Jobber to answer, by the vocabulary's names.
|
|
3
3
|
class Mock
|
|
4
|
-
# The canned answers, each read by the matching
|
|
5
|
-
attr_accessor :
|
|
6
|
-
:oauth_error
|
|
4
|
+
# The canned answers, each read by the matching mock collection.
|
|
5
|
+
attr_accessor :business, :quote, :job, :invoice, :lead, :jobs, :visits, :technicians,
|
|
6
|
+
:oauth_url, :oauth_error
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
# Touching it once turns every account over to its mock counterpart.
|
|
11
|
+
# @return [Mock] answers this process gives, created on first use.
|
|
12
|
+
def mock = @mock ||= Mock.new
|
|
13
|
+
|
|
14
|
+
# @return [Boolean] whether an app under test has switched Jobber off.
|
|
15
|
+
def mocked? = !@mock.nil?
|
|
16
|
+
|
|
17
|
+
# Set to nil, hands the accounts back to Jobber.
|
|
18
|
+
# @return [Mock, nil] answers this process gives.
|
|
19
|
+
attr_writer :mock
|
|
7
20
|
end
|
|
8
21
|
end
|
data/lib/jbr/phone.rb
CHANGED
|
@@ -4,20 +4,17 @@ module Jbr
|
|
|
4
4
|
# What Jobber is asked for wherever a client is read: the number, and what ranks it.
|
|
5
5
|
SELECTION = 'phones { normalizedPhoneNumber smsAllowed primary }'
|
|
6
6
|
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
# @param phones [Array<Hash>, nil] the numbers as Jobber answered them.
|
|
12
|
-
# @return [String, nil] the ten digits to call, or nil where none can be dialed.
|
|
7
|
+
# The primary number first, then one that takes texts, then the rest as filed; the first
|
|
8
|
+
# of them that can be dialed is the one.
|
|
9
|
+
# @param phones [Array<Hash>, nil] numbers as Jobber answered them.
|
|
10
|
+
# @return [String, nil] ten digits to call, or nil where none can be dialed.
|
|
13
11
|
def self.from(phones)
|
|
14
12
|
# The index breaks a tie because sort_by does not: two equally ranked numbers would
|
|
15
13
|
# otherwise swap between runs, and the client would answer a different phone each time.
|
|
16
14
|
ranked = Array(phones).each_with_index.sort_by do |phone, index|
|
|
17
15
|
[ phone['primary'] ? 0 : 1, phone['smsAllowed'] ? 0 : 1, index ]
|
|
18
16
|
end
|
|
19
|
-
|
|
20
|
-
numbers.filter_map { |number| number[NORTH_AMERICAN, 1] }.first
|
|
17
|
+
ranked.lazy.filter_map { |phone, _| Company::Phone.from phone['normalizedPhoneNumber'] }.first
|
|
21
18
|
end
|
|
22
19
|
end
|
|
23
20
|
end
|
data/lib/jbr/querying.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# What credentials do with a statement: post it to Jobber, refresh a stale token once, and
|
|
3
|
+
# raise every refusal as a {Error} so a caller rescues one class.
|
|
4
|
+
module Querying
|
|
5
|
+
# Where every query and mutation is posted.
|
|
6
|
+
ENDPOINT = 'https://api.getjobber.com/api/graphql'
|
|
7
|
+
|
|
8
|
+
# The version of the schema every statement is written against.
|
|
9
|
+
HEADERS = { 'X-JOBBER-GRAPHQL-VERSION' => '2026-04-22' }
|
|
10
|
+
|
|
11
|
+
# How Jobber says the grant does not cover what was asked for: `An object of type User was
|
|
12
|
+
# hidden due to permissions`. It codes this one not at all, so the words are the only signal
|
|
13
|
+
# there is, and it names the type in them. An app granted its scopes before a reader existed
|
|
14
|
+
# would break on every query carrying that reader; this way it carries on a field short.
|
|
15
|
+
HIDDEN = /hidden due to permissions/
|
|
16
|
+
|
|
17
|
+
# The mutation that revokes the app on the account.
|
|
18
|
+
DISCONNECT = <<~GRAPHQL
|
|
19
|
+
mutation Disconnect {
|
|
20
|
+
appDisconnect {
|
|
21
|
+
app { name author }
|
|
22
|
+
userErrors { message }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
GRAPHQL
|
|
26
|
+
|
|
27
|
+
# Nothing here ever sleeps: where Jobber holds the app to a limit it says so, and a caller
|
|
28
|
+
# asking from a background job has a queue that will bring the whole job back later.
|
|
29
|
+
# @param statement [String] query or mutation to run.
|
|
30
|
+
# @param variables [Hash] what the statement takes.
|
|
31
|
+
# @return [Hash] data Jobber answered: empty where the credentials are dead, and short of
|
|
32
|
+
# whatever the grant does not cover, which Jobber hides rather than answers.
|
|
33
|
+
# @raise [Throttled] where Jobber refused the statement for what it costs.
|
|
34
|
+
# @raise [Error] where Jobber refused the statement, or took a mutation and would not act.
|
|
35
|
+
def query(statement, variables: {})
|
|
36
|
+
data = client.query statement, variables: variables
|
|
37
|
+
refusals = user_errors(data).map { |error| error['message'] }
|
|
38
|
+
raise Error, refusals.join('; ') if refusals.any?
|
|
39
|
+
|
|
40
|
+
data
|
|
41
|
+
rescue GraphQL::Unauthorized
|
|
42
|
+
refresh ? retry : {}
|
|
43
|
+
rescue GraphQL::Throttled => error
|
|
44
|
+
raise Throttled, error.message
|
|
45
|
+
rescue GraphQL::Error => error
|
|
46
|
+
raise Error, error.message unless error.message.match? HIDDEN
|
|
47
|
+
|
|
48
|
+
Jbr.logger.warn 'Jobber hid what this app is not granted. Tick the scope for the type it ' \
|
|
49
|
+
"names and have the account authorize again. #{error.message}"
|
|
50
|
+
error.data.to_h
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Revoke the credentials on the account. Dead ones have nothing left to revoke.
|
|
54
|
+
def delete
|
|
55
|
+
client.query DISCONNECT
|
|
56
|
+
rescue GraphQL::Unauthorized
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def client = GraphQL::Client.new endpoint: ENDPOINT, token: @access_token, headers: HEADERS
|
|
62
|
+
|
|
63
|
+
def user_errors(data)
|
|
64
|
+
fields = data.each_value.select { |field| field.is_a? Hash }
|
|
65
|
+
fields.flat_map { |field| Array(field['userErrors']) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/jbr/reader.rb
ADDED
data/lib/jbr/reading.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# What a list read through the credentials holds: what to reach Jobber with, what to bring
|
|
3
|
+
# back beside each record, and what the list was narrowed to. A list of visits answers the
|
|
4
|
+
# vocabulary's own {Company::Visits} rather than {Collection}, so the three live here.
|
|
5
|
+
module Reading
|
|
6
|
+
# @param account [Account] credentials to reach Jobber with.
|
|
7
|
+
# @param includes [Hash] what to bring back beside each record, by name.
|
|
8
|
+
# @param filter [Hash, nil] what the list was narrowed to, in the shape Jobber filters by.
|
|
9
|
+
def initialize(account:, includes: {}, filter: nil)
|
|
10
|
+
@account = account
|
|
11
|
+
@includes = includes
|
|
12
|
+
@filter = filter
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/jbr/refreshing.rb
CHANGED
|
@@ -5,18 +5,12 @@ module Jbr
|
|
|
5
5
|
module Refreshing
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
-
# With no store there is nobody to compare against: refresh, and leave what we end up
|
|
9
|
-
# holding for the caller to persist.
|
|
10
8
|
def refresh
|
|
11
9
|
return exchange unless @store
|
|
12
10
|
|
|
13
11
|
@store.exclusively { |stored| renew stored }
|
|
14
12
|
end
|
|
15
13
|
|
|
16
|
-
# Under the store's lock, holding what it says right now. A token that is no longer the one
|
|
17
|
-
# we tried is one somebody else has already replaced, and adopting it asks Jobber nothing —
|
|
18
|
-
# which is what keeps a queue of workers from refreshing a hundred times over, each with a
|
|
19
|
-
# refresh token the first of them has already spent.
|
|
20
14
|
def renew(stored)
|
|
21
15
|
return adopt stored if stored[:access_token] != @access_token
|
|
22
16
|
|
|
@@ -37,8 +31,6 @@ module Jbr
|
|
|
37
31
|
true
|
|
38
32
|
end
|
|
39
33
|
|
|
40
|
-
# Refused while holding the freshest refresh token there is, so the grant itself is dead
|
|
41
|
-
# rather than our copy being behind.
|
|
42
34
|
def refused
|
|
43
35
|
@invalid_at = Time.now
|
|
44
36
|
@store&.write self
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# A person the business works for, who Jobber calls a client.
|
|
3
|
+
class Customer < Company::Customer
|
|
4
|
+
# The node keys Jobber spells otherwise than the vocabulary.
|
|
5
|
+
def self.keys = { name: :firstName, surname: :lastName }
|
|
6
|
+
|
|
7
|
+
# What Jobber calls each flat field of a client. The phones nest: see {Phone}.
|
|
8
|
+
FIELDS = 'id firstName lastName companyName email'
|
|
9
|
+
|
|
10
|
+
# What to ask for wherever a record names its client.
|
|
11
|
+
SELECTION = "client { #{FIELDS} #{Phone::SELECTION} }"
|
|
12
|
+
|
|
13
|
+
# Jobber files nobody without one name or the other, so there is always one to call them.
|
|
14
|
+
# @return [String, nil] first name, or the business's name where a person has none.
|
|
15
|
+
def name = super.presence || attribute(:companyName).presence
|
|
16
|
+
|
|
17
|
+
# @return [String, nil] ten digits they are reached on, nil where none can be dialed.
|
|
18
|
+
def phone = Phone.from @node[:phones]
|
|
19
|
+
|
|
20
|
+
# @return [Array<Location>] places on their file, where the query asked for them.
|
|
21
|
+
def locations = @node.dig(:clientProperties, :nodes).to_a.map { |node| Location.new node: node }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# A bill the business issued for finished work, which Jobber alone answers for.
|
|
3
|
+
class Invoice < Company::Resource
|
|
4
|
+
# What every invoice reads.
|
|
5
|
+
def self.attributes = %i[id amount completed_at issued_at]
|
|
6
|
+
|
|
7
|
+
# The node keys Jobber spells otherwise than the readers.
|
|
8
|
+
def self.keys = { amount: :total, issued_at: :issuedDate }
|
|
9
|
+
|
|
10
|
+
# Jobber lists the jobs an invoice bills, and one it bills one.
|
|
11
|
+
# @return [Job, nil] job the invoice bills, where Jobber listed one.
|
|
12
|
+
def job
|
|
13
|
+
node = @node.dig :jobs, :nodes, 0
|
|
14
|
+
Job.new node: node if node
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [BigDecimal, nil] what the invoice comes to, in dollars.
|
|
18
|
+
def amount = decimal :amount
|
|
19
|
+
|
|
20
|
+
# @return [Time, nil] moment the billed work was finished, or the bill issued where undated.
|
|
21
|
+
def fulfilled_at = completed_at || issued_at
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def completed_at = job&.completed_at
|
|
26
|
+
|
|
27
|
+
def issued_at = time :issued_at
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# Work the business accepted and scheduled.
|
|
3
|
+
class Job < Company::Job
|
|
4
|
+
# The node keys Jobber spells otherwise than the vocabulary.
|
|
5
|
+
def self.keys
|
|
6
|
+
{ description: :title, notes: :instructions, created_at: :createdAt,
|
|
7
|
+
scheduled_at: :startAt, completed_at: :completedAt, amount: :total, }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# @return [Quote, nil] quote the job was won with, where Jobber filed one beside it.
|
|
11
|
+
def quote = record Quote, :quote
|
|
12
|
+
|
|
13
|
+
# @return [Array<Line>] lines the job is made of, empty where the query never asked for
|
|
14
|
+
# them: a page costs what it carries, so nothing nested arrives unasked.
|
|
15
|
+
def lines = @node.dig(:lineItems, :nodes).to_a.map { |node| Line.new node: node }
|
|
16
|
+
|
|
17
|
+
# @return [Location, nil] where the work happens, where it came back beside the job.
|
|
18
|
+
def location = record Location, :property
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# One line of the work a job is made of.
|
|
3
|
+
class Line < Company::Line
|
|
4
|
+
# The node keys Jobber spells otherwise than the vocabulary.
|
|
5
|
+
def self.keys = { amount: :totalPrice }
|
|
6
|
+
|
|
7
|
+
# The most lines to read off one record. Bounded because Jobber prices a connection by the
|
|
8
|
+
# page it is asked for and prices an unbounded one at its own maximum, so the lines of a
|
|
9
|
+
# page of jobs were charged for as though every job had the largest job's worth of them.
|
|
10
|
+
PAGE = 20
|
|
11
|
+
|
|
12
|
+
# What to ask for wherever a record lists the lines it is made of.
|
|
13
|
+
SELECTION = "lineItems(first: #{PAGE}) { nodes { #{node_keys.join ' '} } }"
|
|
14
|
+
end
|
|
15
|
+
end
|