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
@@ -0,0 +1,31 @@
1
+ module Jbr
2
+ # Where work happens, which Jobber calls a property: one address on a client's file.
3
+ class Location < Company::Location
4
+ # The node keys Jobber spells otherwise than the vocabulary.
5
+ def self.keys = { street: :street1, zip: :postalCode }
6
+
7
+ # The address as Jobber answers it, the coordinates tucked inside.
8
+ ADDRESS = 'street1 city postalCode coordinates { latitude longitude }'
9
+
10
+ # What to ask for wherever a record names the place the work happens at.
11
+ # @param customer [Boolean] whether whose place it is comes back beside it, which spares a
12
+ # second query for somebody Jobber already knows the place belongs to.
13
+ # @return [String] selection of a property.
14
+ def self.selection(customer: false)
15
+ [ 'property { id address {', ADDRESS, '}', (Customer::SELECTION if customer), '}' ].
16
+ compact.join ' '
17
+ end
18
+
19
+ # Jobber answers a field it holds nothing for with an empty string as readily as with
20
+ # null, and the two arrive as the same nothing.
21
+ # @param node [Hash] property as Jobber answered it, the address and its coordinates nested.
22
+ def initialize(node: {})
23
+ node = node.with_indifferent_access
24
+ address, coordinates = node[:address].to_h, node.dig(:address, :coordinates).to_h
25
+ super node: node.merge(address, coordinates).compact_blank
26
+ end
27
+
28
+ # @return [Customer, nil] whose place it is, where they came back beside the location.
29
+ def customer = record Customer, :client
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ module Jbr
2
+ # A price the business sent to answer a lead, which Jobber calls a request.
3
+ class Quote < Company::Quote
4
+ # Jobber alone files the request a quote answers beside it; the vocabulary knows no such link.
5
+ # @return [Lead, nil] request the quote answers, where Jobber filed one beside it.
6
+ def lead = record Lead, :request
7
+
8
+ # Jobber files the total among the quote's amounts.
9
+ # @return [BigDecimal, nil] what the quote comes to, in dollars.
10
+ def amount
11
+ total = @node.dig :amounts, :total
12
+ BigDecimal total.to_s if total.present?
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ module Jbr
2
+ # A person the business sends out, whom Jobber calls a user and whose name is a node of its
3
+ # own rather than two fields, so the vocabulary's keys would not fetch it.
4
+ class Technician < Company::Technician
5
+ # What Jobber answers a user with.
6
+ FIELDS = 'id name { first last }'
7
+
8
+ # What to ask for wherever a scheduled item names who it is booked for: ten, which is more
9
+ # than a stop is ever booked for. An app never granted the Users scope has the whole query
10
+ # refused rather than this field left empty, so nothing asks for it unbidden.
11
+ SELECTION = "assignedUsers(first: 10) { nodes { #{FIELDS} } }"
12
+
13
+ # @return [String, nil] what they go by: a given name.
14
+ def name = @node.dig :name, :first
15
+
16
+ # @return [String, nil] their surname.
17
+ def surname = @node.dig :name, :last
18
+ end
19
+ end
@@ -0,0 +1,30 @@
1
+ module Jbr
2
+ # One stop of a job: when the work is scheduled to happen, where the job does.
3
+ class Visit < Company::Visit
4
+ # What every visit reads: the vocabulary's names, and whether the client confirmed it,
5
+ # which Jobber alone asks.
6
+ def self.attributes = super + %i[confirmed]
7
+
8
+ # The node keys Jobber spells otherwise than the vocabulary.
9
+ def self.keys
10
+ { description: :title, starts_at: :startAt, ends_at: :endAt, anytime: :allDay,
11
+ confirmed: :clientConfirmed, }
12
+ end
13
+
14
+ # @return [Boolean, nil] whether the client confirmed the visit.
15
+ def confirmed? = attribute :confirmed
16
+
17
+ # Jobber calls the lead a request, and hangs the assessment booked to look at the work off
18
+ # it; a stop of a job names no request and a stop of a lead names no job.
19
+ # @return [Lead, nil] lead the stop belongs to, nil where the stop is a job's.
20
+ def lead = record Lead, :request
21
+
22
+ # Jobber hangs the property off each kind of scheduled item rather than off what they
23
+ # share, so it is asked for inside each and read back under Jobber's own name for it.
24
+ # @return [Location, nil] where the stop happens, where the query asked and Jobber has one.
25
+ def location = record Location, :property
26
+
27
+ # @return [Array<Technician>] whoever the stop is booked for, where the query asked.
28
+ def technicians = @node.dig(:assignedUsers, :nodes).to_a.map { Technician.new node: it }
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ module Jbr
2
+ # What Jobber files as a scheduled item, and how a list of them is asked for. Which kinds count
3
+ # as booked time lives here, and so does the awkward part: a scheduled item names no property
4
+ # and no job, so where a stop is and what it was booked for hang off each kind rather than off
5
+ # what they share, and are selected inside the fragments.
6
+ module Scheduled
7
+ # What every kind answers with. Jobber has four of them -- a visit, an assessment, an event
8
+ # and a task -- and every one is an hour somebody is out, so no kind is read past. A reminder
9
+ # is named by the filter's enum but implements nothing here, and cannot come back.
10
+ SHARED = 'id title startAt endAt allDay'
11
+
12
+ private
13
+
14
+ def fields
15
+ [ SHARED, kind('Visit', 'clientConfirmed job { id }'),
16
+ kind('Assessment', 'clientConfirmed request { id }') ].join ' '
17
+ end
18
+
19
+ def kind(name, own) = "... on #{name} { #{[ own, located ].compact_blank.join ' '} }"
20
+
21
+ # Included before Includable so this is found first: a location asked for is selected inside
22
+ # each kind's fragment by `kind`, and must not also be appended to what they share, where
23
+ # Jobber has no property to answer with.
24
+ def selection_of(name, nested)
25
+ return if name == :location
26
+
27
+ super
28
+ end
29
+
30
+ def located
31
+ Location.selection customer: @includes[:location] == :customer if @includes.key? :location
32
+ end
33
+
34
+ def item(node) = Visit.new node: node
35
+ end
36
+ end
data/lib/jbr/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # A Ruby client for the Jobber API.
2
2
  module Jbr
3
3
  # The version of this gem.
4
- VERSION = '3.13.0'
4
+ VERSION = '5.0.0'
5
5
  end
data/lib/jbr.rb CHANGED
@@ -1,68 +1,68 @@
1
1
  require 'json'
2
2
  require 'net/http'
3
3
 
4
- # Only the three 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. The third is
7
- # for the sentence a job's lines read as.
8
- require 'active_support/core_ext/object/blank'
4
+ # The vocabulary every record here answers in, and the Active Support it brings along. Two
5
+ # more files of it: an address is sent without the fields a caller left blank, and a window
6
+ # with no end reaches a year out.
7
+ require 'company'
9
8
  require 'active_support/core_ext/enumerable'
10
- require 'active_support/core_ext/array/conversions'
9
+ require 'active_support/core_ext/integer/time'
11
10
 
12
11
  require 'graphql/error'
13
12
  require 'graphql/unauthorized'
14
13
  require 'graphql/throttled'
15
14
  require 'graphql/client'
16
15
 
17
- require 'jbr/mock'
18
-
19
- require 'jbr/url'
16
+ require 'jbr/version'
17
+ require 'jbr/logger'
20
18
  require 'jbr/error'
21
- require 'jbr/refused'
22
- require 'jbr/retriable'
19
+ require 'jbr/errors/refused'
20
+ require 'jbr/errors/throttled'
23
21
  require 'jbr/token'
24
22
  require 'jbr/refreshing'
25
- # Phone before Cliental, and Cliental before the records that include it: what each asks
26
- # Jobber for about a client is built as they load.
23
+ require 'jbr/querying'
24
+ require 'jbr/authorizing'
25
+ require 'jbr/mock'
26
+
27
+ # Phone before Customer, and Location before Customers: what each asks Jobber for is built as
28
+ # it loads. Every record before the collection that reads it, for the same reason.
27
29
  require 'jbr/phone'
28
- require 'jbr/cliental'
29
- require 'jbr/named'
30
- require 'jbr/resource'
31
- require 'jbr/request'
32
- require 'jbr/oauth'
30
+ require 'jbr/resources/customer'
31
+ require 'jbr/resources/location'
32
+ require 'jbr/resources/line'
33
+ require 'jbr/resources/job'
34
+ require 'jbr/resources/technician'
35
+ require 'jbr/resources/visit'
36
+ require 'jbr/resources/quote'
37
+ require 'jbr/resources/invoice'
38
+ require 'jbr/resources/lead'
33
39
 
34
- require 'jbr/account'
35
- # Property comes before Client and Visit: their queries read its fields as they load.
36
- require 'jbr/property'
37
- require 'jbr/properted'
40
+ require 'jbr/reader'
41
+ require 'jbr/reading'
42
+ require 'jbr/collection'
38
43
  require 'jbr/includable'
39
44
  require 'jbr/listable'
40
- # LineItem before Itemized, and both before Job: the lines a job is made of are asked for
41
- # by a constant the include reads as it loads.
42
- require 'jbr/line_item'
43
- require 'jbr/itemized'
44
- require 'jbr/client'
45
- require 'jbr/invoice'
46
- require 'jbr/job'
47
- require 'jbr/jobs'
48
- require 'jbr/quote'
49
- require 'jbr/visit'
50
- require 'jbr/visits'
45
+ require 'jbr/scheduled'
46
+ require 'jbr/booking'
47
+ require 'jbr/collections/jobs'
48
+ require 'jbr/collections/visits'
49
+ require 'jbr/collections/technicians'
50
+ require 'jbr/collections/quotes'
51
+ require 'jbr/collections/invoices'
52
+ require 'jbr/collections/customers'
53
+ require 'jbr/collections/locations'
54
+ require 'jbr/collections/leads'
55
+ require 'jbr/account'
51
56
 
52
- require 'jbr/mock/oauth'
53
- require 'jbr/mock/client'
54
- require 'jbr/mock/property'
55
- require 'jbr/mock/quote'
56
- require 'jbr/mock/line_item'
57
- require 'jbr/mock/job'
58
57
  require 'jbr/mock/jobs'
59
- require 'jbr/mock/invoice'
60
- require 'jbr/mock/request'
61
- require 'jbr/mock/account'
62
- require 'jbr/mock/url'
63
58
  require 'jbr/mock/visit'
64
59
  require 'jbr/mock/visits'
65
-
66
- require 'jbr/mocking'
60
+ require 'jbr/mock/technicians'
61
+ require 'jbr/mock/quote'
62
+ require 'jbr/mock/quotes'
63
+ require 'jbr/mock/invoice'
64
+ require 'jbr/mock/invoices'
65
+ require 'jbr/mock/leads'
66
+ require 'jbr/mock/account'
67
67
 
68
68
  require 'jbr/event'
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: 3.13.0
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: company
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.1'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: minitest
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -110,48 +124,52 @@ files:
110
124
  - lib/graphql/unauthorized.rb
111
125
  - lib/jbr.rb
112
126
  - lib/jbr/account.rb
113
- - lib/jbr/client.rb
114
- - lib/jbr/cliental.rb
127
+ - lib/jbr/authorizing.rb
128
+ - lib/jbr/booking.rb
129
+ - lib/jbr/collection.rb
130
+ - lib/jbr/collections/customers.rb
131
+ - lib/jbr/collections/invoices.rb
132
+ - lib/jbr/collections/jobs.rb
133
+ - lib/jbr/collections/leads.rb
134
+ - lib/jbr/collections/locations.rb
135
+ - lib/jbr/collections/quotes.rb
136
+ - lib/jbr/collections/technicians.rb
137
+ - lib/jbr/collections/visits.rb
115
138
  - lib/jbr/error.rb
139
+ - lib/jbr/errors/refused.rb
140
+ - lib/jbr/errors/throttled.rb
116
141
  - lib/jbr/event.rb
117
142
  - lib/jbr/includable.rb
118
- - lib/jbr/invoice.rb
119
- - lib/jbr/itemized.rb
120
- - lib/jbr/job.rb
121
- - lib/jbr/jobs.rb
122
- - lib/jbr/line_item.rb
123
143
  - lib/jbr/listable.rb
144
+ - lib/jbr/logger.rb
124
145
  - lib/jbr/mock.rb
125
146
  - lib/jbr/mock/account.rb
126
- - lib/jbr/mock/client.rb
127
147
  - lib/jbr/mock/invoice.rb
128
- - lib/jbr/mock/job.rb
148
+ - lib/jbr/mock/invoices.rb
129
149
  - lib/jbr/mock/jobs.rb
130
- - lib/jbr/mock/line_item.rb
131
- - lib/jbr/mock/oauth.rb
132
- - lib/jbr/mock/property.rb
150
+ - lib/jbr/mock/leads.rb
133
151
  - lib/jbr/mock/quote.rb
134
- - lib/jbr/mock/request.rb
135
- - lib/jbr/mock/url.rb
152
+ - lib/jbr/mock/quotes.rb
153
+ - lib/jbr/mock/technicians.rb
136
154
  - lib/jbr/mock/visit.rb
137
155
  - lib/jbr/mock/visits.rb
138
- - lib/jbr/mocking.rb
139
- - lib/jbr/named.rb
140
- - lib/jbr/oauth.rb
141
156
  - lib/jbr/phone.rb
142
- - lib/jbr/properted.rb
143
- - lib/jbr/property.rb
144
- - lib/jbr/quote.rb
157
+ - lib/jbr/querying.rb
158
+ - lib/jbr/reader.rb
159
+ - lib/jbr/reading.rb
145
160
  - lib/jbr/refreshing.rb
146
- - lib/jbr/refused.rb
147
- - lib/jbr/request.rb
148
- - lib/jbr/resource.rb
149
- - lib/jbr/retriable.rb
161
+ - lib/jbr/resources/customer.rb
162
+ - lib/jbr/resources/invoice.rb
163
+ - lib/jbr/resources/job.rb
164
+ - lib/jbr/resources/lead.rb
165
+ - lib/jbr/resources/line.rb
166
+ - lib/jbr/resources/location.rb
167
+ - lib/jbr/resources/quote.rb
168
+ - lib/jbr/resources/technician.rb
169
+ - lib/jbr/resources/visit.rb
170
+ - lib/jbr/scheduled.rb
150
171
  - lib/jbr/token.rb
151
- - lib/jbr/url.rb
152
172
  - lib/jbr/version.rb
153
- - lib/jbr/visit.rb
154
- - lib/jbr/visits.rb
155
173
  homepage: https://github.com/HouseAccountEng/jbr
156
174
  licenses:
157
175
  - MIT
data/lib/jbr/client.rb DELETED
@@ -1,98 +0,0 @@
1
- module Jbr
2
- # A person a Jobber user works for, and the property the work happens at.
3
- class Client < Resource
4
- # The query that finds a client by phone, with the properties already on file.
5
- LOOKUP = <<~GRAPHQL
6
- query($searchTerm: String!) {
7
- clientPhones(first: 1, searchTerm: $searchTerm) { nodes {
8
- client { id updatedAt clientProperties { nodes { id address { #{Property::SELECTION} } }} }
9
- } }
10
- }
11
- GRAPHQL
12
-
13
- # The mutation that opens a client, with a first property when an address is given.
14
- CREATE = <<~GRAPHQL
15
- mutation($input: ClientCreateInput!) {
16
- clientCreate(input: $input) {
17
- client { id clientProperties(first: 1) { nodes { id } } }
18
- userErrors { message }
19
- }
20
- }
21
- GRAPHQL
22
-
23
- # @return [String, nil] the property the work happens at.
24
- attr_reader :property_id
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
-
45
- # Create a client instance with the provided attributes.
46
- # @return [Client] itself
47
- # @param params [Hash] the attributes of the client
48
- # @option params [String] :first_name the client’s first name
49
- # @option params [String] :last_name the client’s last name
50
- # @option params [String] :phone the client’s phone number
51
- # @option params [<String, nil>] :email the client’s email address
52
- def create_with(params = {})
53
- self.tap { @create_params = params }
54
- end
55
-
56
- # Reach the client behind a phone number, opening one if Jobber has none.
57
- # @param phone [String] the number to match on.
58
- # @return [Client] itself.
59
- def find_or_create_by(phone:)
60
- find_by_phone(phone) || create
61
- self
62
- end
63
-
64
- private
65
-
66
- def find_by_phone(phone)
67
- output = @oauth.query LOOKUP, variables: { searchTerm: phone }
68
- recent = (output.dig('clientPhones', 'nodes') || []).max_by do |clients|
69
- clients.dig('client', 'updatedAt') || ''
70
- end
71
- return unless recent
72
-
73
- @id = recent.dig 'client', 'id'
74
- @property_id = Property.new(oauth: @oauth).find_or_create_for client_id: @id,
75
- address: @create_params[:address],
76
- existing: recent.dig('client', 'clientProperties', 'nodes') || []
77
- true
78
- end
79
-
80
- def create
81
- output = @oauth.query CREATE, variables: { input: input }
82
- @id = output.dig 'clientCreate', 'client', 'id'
83
-
84
- properties = output.dig('clientCreate', 'client', 'clientProperties', 'nodes') || []
85
- @property_id = (properties.first || {})['id']
86
- end
87
-
88
- def input
89
- address, email = @create_params[:address], @create_params[:email]
90
- { firstName: @create_params[:first_name],
91
- lastName: @create_params[:last_name],
92
- properties: ([ { address: Property.address_from(address) } ] if address.present?),
93
- phones: [ { number: @create_params[:phone], primary: true } ],
94
- emails: ([ { address: email, primary: true } ] if email.present?),
95
- }.compact
96
- end
97
- end
98
- end
data/lib/jbr/cliental.rb DELETED
@@ -1,14 +0,0 @@
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
data/lib/jbr/invoice.rb DELETED
@@ -1,43 +0,0 @@
1
- module Jbr
2
- # A bill a Jobber user issued for finished work.
3
- class Invoice < Resource
4
- # The query that reads one invoice, its total, its date and the job it bills.
5
- FIND = <<~GRAPHQL
6
- query($id: EncodedId!) {
7
- invoice(id: $id) { id total invoiceStatus issuedDate
8
- jobs { nodes { id completedAt } } }
9
- }
10
- GRAPHQL
11
-
12
- # @return [String, nil] the ID of the job billed, and the amount as Jobber writes it.
13
- attr_reader :job_id, :total
14
-
15
- # @param id [String] the Jobber ID of the invoice.
16
- # @return [Invoice, nil] itself, or nil when the invoice is missing or still a draft.
17
- def find(id)
18
- output = @oauth.query FIND, variables: { id: id }
19
- return unless invoice = output['invoice']
20
- return if invoice['invoiceStatus'].eql? 'draft'
21
-
22
- @id = invoice['id']
23
- @total = invoice['total']
24
- @issued_at = invoice['issuedDate']
25
-
26
- job = invoice.dig('jobs', 'nodes', 0) || {}
27
- @job_id = job['id']
28
- @completed_at = job['completedAt']
29
-
30
- self
31
- end
32
-
33
- # @return [Date] the invoice issued time
34
- def issued_at
35
- Time.iso8601(@issued_at) if @issued_at
36
- end
37
-
38
- # @return [Time] the job completed time
39
- def completed_at
40
- Time.iso8601(@completed_at) if @completed_at
41
- end
42
- end
43
- end
data/lib/jbr/itemized.rb DELETED
@@ -1,9 +0,0 @@
1
- module Jbr
2
- # Extends a record Jobber itemizes: a job, whose lines say what the work actually was
3
- # where the title only says what somebody called it.
4
- module Itemized
5
- # @return [Array<LineItem>] the lines the record is made of, empty where the query never
6
- # asked for them — a page costs what it carries, so nothing nested arrives unasked.
7
- def line_items = LineItem.from @node.dig('lineItems', 'nodes')
8
- end
9
- end
data/lib/jbr/job.rb DELETED
@@ -1,40 +0,0 @@
1
- module Jbr
2
- # Work a Jobber user accepted and scheduled.
3
- class Job < Resource
4
- include Cliental, Itemized, 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
- # What the job's lines add up to, each as how many of what: `3 Faucet install and 2 Valve
13
- # change` — `to_sentence` reading each line's own string form. The lines say what the work
14
- # was where a title only says what it was called, so this reads better than one, and falls
15
- # back to {#name} where the job has no lines or the query never asked for them.
16
- # @return [String] the lines as a sentence, or the title, or the ID. Never nil, never empty.
17
- def summary = line_items.to_sentence.presence || name
18
-
19
- # @return [String, nil] where Jobber files the job in its own workflow.
20
- def status = @node['jobStatus']
21
-
22
- # @return [String, nil] the ID of the quote the job was won with.
23
- def quote_id = @node.dig 'quote', 'id'
24
-
25
- # @return [Float, nil] what the job comes to.
26
- def total = @node['total']
27
-
28
- # @return [Float, nil] what the quote the job was won with came to.
29
- def quote_total = @node.dig 'quote', 'amounts', 'total'
30
-
31
- # @return [Time, nil] the job opening time
32
- def created_at = time 'createdAt'
33
-
34
- # @return [Time, nil] the job scheduled time
35
- def scheduled_at = time 'startAt'
36
-
37
- # @return [Time, nil] the job completed time
38
- def completed_at = time 'completedAt'
39
- end
40
- end
data/lib/jbr/line_item.rb DELETED
@@ -1,44 +0,0 @@
1
- module Jbr
2
- # One line of the work a job is made of: how many of a thing, what it is called,
3
- # what it says, and what it comes to.
4
- class LineItem < Resource
5
- # What Jobber calls each field of a line. The ID reads through {Resource#id}, so an app
6
- # can tell a line it has seen before from a new one.
7
- FIELDS = %w[id quantity name description totalPrice]
8
-
9
- # The most lines to read off one record. Bounded because Jobber prices a connection by the
10
- # page it is asked for and prices an unbounded one at its own maximum, so the lines of a
11
- # page of jobs were charged for as though every job had the largest job's worth of them.
12
- PAGE = 20
13
-
14
- # What to ask for wherever a record lists the lines it is made of.
15
- SELECTION = "lineItems(first: #{PAGE}) { nodes { #{FIELDS.join ' '} } }"
16
-
17
- # @param nodes [Array<Hash>, nil] the lines as Jobber answered them, if it answered any.
18
- # @return [Array<LineItem>] one per line, in the order Jobber holds them.
19
- def self.from(nodes) = nodes.to_a.map { |node| new node: node }
20
-
21
- # @return [Integer, Float, nil] how many of it the job is for.
22
- def quantity = whole @node['quantity']
23
-
24
- # @return [String, nil] what the line is called.
25
- def name = @node['name']
26
-
27
- # @return [String, nil] what the line says, beyond what it is called.
28
- def description = @node['description']
29
-
30
- # @return [Float, nil] what the line comes to — what Jobber calls `totalPrice`.
31
- def amount = @node['totalPrice']
32
-
33
- # @return [String] how many of what: `3 Bathroom Faucet Installation`, and the name alone
34
- # where Jobber holds no quantity for the line.
35
- def to_s = [ quantity, name ].compact.join ' '
36
-
37
- private
38
-
39
- # Jobber answers every quantity as a Float, and a whole one reads as an Integer:
40
- # `3 Faucets` rather than `3.0 Faucets`. A fraction keeps its point — `3.5 Faucets` —
41
- # since rounding it would lie about what was billed.
42
- def whole(number) = number && ((number % 1).zero? ? number.to_i : number)
43
- end
44
- end
@@ -1,17 +0,0 @@
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