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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/CHANGELOG.md +139 -0
  4. data/README.md +302 -167
  5. data/lib/graphql/client.rb +2 -6
  6. data/lib/graphql/error.rb +10 -0
  7. data/lib/graphql/throttled.rb +0 -9
  8. data/lib/jbr/account.rb +48 -15
  9. data/lib/jbr/authorizing.rb +43 -0
  10. data/lib/jbr/booking.rb +56 -0
  11. data/lib/jbr/collection.rb +7 -0
  12. data/lib/jbr/collections/customers.rb +54 -0
  13. data/lib/jbr/collections/invoices.rb +18 -0
  14. data/lib/jbr/{jobs.rb → collections/jobs.rb} +8 -8
  15. data/lib/jbr/collections/leads.rb +30 -0
  16. data/lib/jbr/collections/locations.rb +47 -0
  17. data/lib/jbr/collections/quotes.rb +18 -0
  18. data/lib/jbr/collections/technicians.rb +17 -0
  19. data/lib/jbr/collections/visits.rb +61 -0
  20. data/lib/jbr/error.rb +1 -1
  21. data/lib/jbr/errors/throttled.rb +6 -0
  22. data/lib/jbr/includable.rb +10 -8
  23. data/lib/jbr/listable.rb +17 -35
  24. data/lib/jbr/logger.rb +13 -0
  25. data/lib/jbr/mock/account.rb +28 -7
  26. data/lib/jbr/mock/invoice.rb +7 -11
  27. data/lib/jbr/mock/invoices.rb +7 -0
  28. data/lib/jbr/mock/jobs.rb +10 -6
  29. data/lib/jbr/mock/leads.rb +7 -0
  30. data/lib/jbr/mock/quote.rb +3 -8
  31. data/lib/jbr/mock/quotes.rb +7 -0
  32. data/lib/jbr/mock/technicians.rb +12 -0
  33. data/lib/jbr/mock/visit.rb +9 -16
  34. data/lib/jbr/mock/visits.rb +39 -6
  35. data/lib/jbr/mock.rb +17 -4
  36. data/lib/jbr/phone.rb +5 -8
  37. data/lib/jbr/querying.rb +68 -0
  38. data/lib/jbr/reader.rb +9 -0
  39. data/lib/jbr/reading.rb +15 -0
  40. data/lib/jbr/refreshing.rb +0 -8
  41. data/lib/jbr/resources/customer.rb +23 -0
  42. data/lib/jbr/resources/invoice.rb +29 -0
  43. data/lib/jbr/resources/job.rb +20 -0
  44. data/lib/jbr/resources/lead.rb +7 -0
  45. data/lib/jbr/resources/line.rb +15 -0
  46. data/lib/jbr/resources/location.rb +31 -0
  47. data/lib/jbr/resources/quote.rb +15 -0
  48. data/lib/jbr/resources/technician.rb +19 -0
  49. data/lib/jbr/resources/visit.rb +30 -0
  50. data/lib/jbr/scheduled.rb +36 -0
  51. data/lib/jbr/version.rb +1 -1
  52. data/lib/jbr.rb +45 -45
  53. metadata +46 -28
  54. data/lib/jbr/client.rb +0 -98
  55. data/lib/jbr/cliental.rb +0 -14
  56. data/lib/jbr/invoice.rb +0 -43
  57. data/lib/jbr/itemized.rb +0 -9
  58. data/lib/jbr/job.rb +0 -40
  59. data/lib/jbr/line_item.rb +0 -44
  60. data/lib/jbr/mock/client.rb +0 -17
  61. data/lib/jbr/mock/job.rb +0 -31
  62. data/lib/jbr/mock/line_item.rb +0 -16
  63. data/lib/jbr/mock/oauth.rb +0 -22
  64. data/lib/jbr/mock/property.rb +0 -19
  65. data/lib/jbr/mock/request.rb +0 -12
  66. data/lib/jbr/mock/url.rb +0 -9
  67. data/lib/jbr/mocking.rb +0 -30
  68. data/lib/jbr/named.rb +0 -9
  69. data/lib/jbr/oauth.rb +0 -94
  70. data/lib/jbr/properted.rb +0 -17
  71. data/lib/jbr/property.rb +0 -93
  72. data/lib/jbr/quote.rb +0 -25
  73. data/lib/jbr/request.rb +0 -37
  74. data/lib/jbr/resource.rb +0 -26
  75. data/lib/jbr/retriable.rb +0 -20
  76. data/lib/jbr/url.rb +0 -16
  77. data/lib/jbr/visit.rb +0 -24
  78. data/lib/jbr/visits.rb +0 -36
  79. /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 property and its
10
- # client priced past what a bucket holds. Half the page costs half the query and loses
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
- # @param within [ActiveSupport::Duration, Numeric, nil] how far ahead to look, or nil for
23
- # as far ahead as the account is scheduled.
24
- # @return [Listable] the same list, narrowed to what is scheduled from now on.
25
- def upcoming(within = nil)
26
- now = Time.now
27
- narrowed after: now, before: (now + within if within)
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 {#find}.
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
- # The two halves of a schedule, split at the moment they are asked for rather than per
47
- # page: read page by page the boundary would slide, and something could cross it unseen.
48
- # The same moment is the near end of a window, so the far end is measured from it too.
49
- def narrowed(after: nil, before: nil)
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
- return true unless @filter
42
+ bounds = @filter&.dig :startAt
43
+ return true unless bounds
60
44
 
61
- after, before = @filter[:startAt].values_at :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 = @oauth.query statement, variables: { after: after, filter: @filter }.compact
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
@@ -1,15 +1,36 @@
1
1
  module Jbr
2
- # The account that reads from {Jbr.mock} instead of Jobber.
2
+ # Credentials that answer from {Jbr.mock} instead of Jobber.
3
3
  class Mock::Account < Account
4
- # @return [Object, nil] the values the app asked for, and 'account-01' for an ID it did not.
5
- def id = node.fetch :id, 'account-01'
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
- def name = node[:name]
8
+ # @return [Mock::Jobs] jobs the app listed.
9
+ def jobs = Mock::Jobs.new account: self
8
10
 
9
- def phone = node[:phone]
11
+ # @return [Mock::Visits] visits the app listed.
12
+ def visits = Mock::Visits.new account: self
10
13
 
11
- private
14
+ # @return [Mock::Technicians] technicians the app listed.
15
+ def technicians = Mock::Technicians.new account: self
12
16
 
13
- def node = Jbr.mock.account.to_h
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
@@ -1,18 +1,14 @@
1
1
  module Jbr
2
- # An invoice that reads from {Jbr.mock} instead of Jobber.
2
+ # An invoice an app under test named, keyed by the readers' names rather than Jobber's.
3
3
  class Mock::Invoice < Invoice
4
- # @return [Mock::Invoice] itself, carrying the mocked IDs and total.
5
- def find(_)
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
- self
11
- end
7
+ # @return [Company::Job, nil] job the app named beside the invoice.
8
+ def job = record Company::Job, :job
12
9
 
13
- # @return [Time, nil] the dates the app asked for.
14
- def issued_at = Jbr.mock.invoice[:issued_at]
10
+ private
15
11
 
16
- def completed_at = Jbr.mock.invoice[: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 which is every app that mocks a lookup without mocking a list.
7
- # @return [Mock::Job] the job asked for.
8
- def find(id) = Mock::Job.new node: listed(id) || Jbr.mock.job
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 { |yielder| selected.each { |job| yielder << Mock::Job.new(node: job) } }
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
@@ -0,0 +1,7 @@
1
+ module Jbr
2
+ # The one lead an app under test asked {Jbr.mock} to answer with.
3
+ class Mock::Leads
4
+ # @return [Company::Lead] lead the app named, whatever was asked to be filed.
5
+ def create(**) = Company::Lead.new node: Jbr.mock.lead.to_h
6
+ end
7
+ end
@@ -1,12 +1,7 @@
1
1
  module Jbr
2
- # A quote that reads from {Jbr.mock} instead of Jobber.
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 [Mock::Quote] itself, carrying the mocked IDs.
5
- def find(_)
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,7 @@
1
+ module Jbr
2
+ # The one quote an app under test asked {Jbr.mock} to answer with.
3
+ class Mock::Quotes
4
+ # @return [Mock::Quote, nil] quote the app named, whatever ID is asked for.
5
+ def find(_id) = (Mock::Quote.new node: Jbr.mock.quote if Jbr.mock.quote)
6
+ end
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
@@ -1,23 +1,16 @@
1
1
  module Jbr
2
- # A visit that reads from {Jbr.mock} instead of Jobber.
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
- # @return [Object, nil] the values the app asked for.
5
- def id = @node[:id]
4
+ # The mock spells every key as the reader is named.
5
+ def self.keys = {}
6
6
 
7
- def title = @node[:title]
7
+ # @return [Array<Company::Technician>] whoever the app said the visit is booked for.
8
+ def technicians = records Company::Technician, :technicians
8
9
 
9
- def job_id = @node[:job_id]
10
+ # @return [Company::Lead, nil] lead the app said the visit was booked against.
11
+ def lead = record Company::Lead, :lead
10
12
 
11
- def client = Mock::Client.new(node: @node.fetch(:client, {}))
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
@@ -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
- # @return [Mock::Visit] the visit the app listed under that ID.
6
- def find(id) = Mock::Visit.new node: listed(id)
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 { |yielder| selected.each { |it| yielder << Mock::Visit.new(node: it) } }
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 selected = Jbr.mock.visits.select { |visit| scheduled? visit[:starts_at] }
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 listed(id) = Jbr.mock.visits.to_a.find { |visit| visit[:id] == id }.to_h
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 Mock resource.
5
- attr_accessor :account, :quote, :job, :invoice, :request, :visits, :jobs, :oauth_url,
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
- # Ten digits whose area code and exchange open with 2 through 9, behind the E.164 +1.
8
- NORTH_AMERICAN = /\A\+1([2-9]\d{2}[2-9]\d{6})\z/
9
-
10
- # The number to reach a client on, without its country code.
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
- numbers = ranked.lazy.map { |phone, _| phone['normalizedPhoneNumber'].to_s }
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
@@ -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
@@ -0,0 +1,9 @@
1
+ module Jbr
2
+ # What every kind reached through the credentials shares: the credentials.
3
+ class Reader
4
+ # @param account [Account] credentials to reach Jobber with.
5
+ def initialize(account:)
6
+ @account = account
7
+ end
8
+ end
9
+ end
@@ -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
@@ -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,7 @@
1
+ module Jbr
2
+ # Somebody who asked the business for work, which Jobber files as a request.
3
+ class Lead < Company::Lead
4
+ # @return [Customer, nil] client the request was opened against.
5
+ def customer = record Customer, :client
6
+ end
7
+ 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