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/graphql/client.rb
CHANGED
|
@@ -28,20 +28,15 @@ module GraphQL
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
# Refused over cost where the endpoint names the code for it, or prices the query above
|
|
32
|
-
# what it says was left. Anything else is a refusal of the query itself.
|
|
33
31
|
def refusal_for(body)
|
|
34
32
|
cost = body['extensions'].to_h['cost'].to_h
|
|
35
33
|
available = cost['throttleStatus'].to_h['currentlyAvailable']
|
|
36
34
|
coded = body['errors'].any? { |error| error.to_h.dig('extensions', 'code') == 'THROTTLED' }
|
|
37
35
|
priced = available && cost['requestedQueryCost'].to_f > available.to_f
|
|
38
36
|
|
|
39
|
-
coded || priced ? Throttled.new
|
|
37
|
+
(coded || priced ? Throttled : Error).new refusal(body), data: body['data']
|
|
40
38
|
end
|
|
41
39
|
|
|
42
|
-
# What the endpoint refused, and — where it priced the refusal — what the query would have
|
|
43
|
-
# cost against what was available. `Throttled` on its own leaves a caller unable to tell a
|
|
44
|
-
# query too big to ever run from a bucket that a moment would have refilled.
|
|
45
40
|
def refusal(body)
|
|
46
41
|
message = body['errors'].map { |error| error['message'] }.join '; '
|
|
47
42
|
cost = body['extensions'].to_h['cost'].to_h
|
|
@@ -51,6 +46,7 @@ module GraphQL
|
|
|
51
46
|
"#{message} (cost #{cost['requestedQueryCost']}, #{status['currentlyAvailable']} of " \
|
|
52
47
|
"#{status['maximumAvailable']} available, restoring #{status['restoreRate']}/s)"
|
|
53
48
|
end
|
|
49
|
+
|
|
54
50
|
def request_headers
|
|
55
51
|
{ 'Authorization' => "Bearer #{@token}", 'Content-Type' => 'application/json' }.merge @headers
|
|
56
52
|
end
|
data/lib/graphql/error.rb
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
module GraphQL
|
|
2
2
|
# An error raised when a GraphQL request fails.
|
|
3
3
|
class Error < StandardError
|
|
4
|
+
# @param message [String] what the API said went wrong.
|
|
5
|
+
# @param data [Hash, nil] what it answered alongside, where it answered anything: an API that
|
|
6
|
+
# refuses one field of a query and fulfils the rest sends both at once.
|
|
7
|
+
def initialize(message = nil, data: nil)
|
|
8
|
+
super message
|
|
9
|
+
@data = data
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Hash, nil] what came back beside the refusal, nil where nothing did.
|
|
13
|
+
attr_reader :data
|
|
4
14
|
end
|
|
5
15
|
end
|
data/lib/graphql/throttled.rb
CHANGED
|
@@ -3,14 +3,5 @@ module GraphQL
|
|
|
3
3
|
# itself. Worth telling apart: the same query is answered once the budget it is priced
|
|
4
4
|
# against has refilled.
|
|
5
5
|
class Throttled < Error
|
|
6
|
-
# @param message [String] what the endpoint said, with the numbers it said it with.
|
|
7
|
-
# @param cost [Hash] what it priced the query at, and the budget it priced it against.
|
|
8
|
-
def initialize(message, cost = {})
|
|
9
|
-
super message
|
|
10
|
-
@cost = cost
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# @return [Hash] those numbers, in the endpoint's own words.
|
|
14
|
-
attr_reader :cost
|
|
15
6
|
end
|
|
16
7
|
end
|
data/lib/jbr/account.rb
CHANGED
|
@@ -1,23 +1,56 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
#
|
|
3
|
-
class Account <
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
{ account { id name phone } }
|
|
7
|
-
GRAPHQL
|
|
2
|
+
# Credentials for one Jobber account, and the gateway to all they read or write.
|
|
3
|
+
class Account < Company::Account
|
|
4
|
+
include Querying, Refreshing
|
|
5
|
+
extend Authorizing
|
|
8
6
|
|
|
9
|
-
#
|
|
10
|
-
|
|
7
|
+
# The query that reads the business behind the credentials, by the keys the vocabulary reads.
|
|
8
|
+
BUSINESS = "{ account { #{Company::Business.node_keys.join ' '} } }"
|
|
11
9
|
|
|
12
|
-
#
|
|
13
|
-
|
|
10
|
+
# Answers from {Jbr.mock} instead of Jobber once an app under test has touched it.
|
|
11
|
+
# @param credentials [Hash] see {#initialize}.
|
|
12
|
+
# @return [Account] credentials, mocked where the app asked for that.
|
|
13
|
+
def self.new(credentials = {})
|
|
14
|
+
Jbr.mocked? && self == Account ? Mock::Account.new(credentials) : super
|
|
15
|
+
end
|
|
14
16
|
|
|
15
|
-
# @
|
|
16
|
-
|
|
17
|
+
# @param credentials [Hash] the tokens, their expiry, the account, when it went bad, and
|
|
18
|
+
# the `store:` these are kept in, where processes share them. See {Refreshing}.
|
|
19
|
+
def initialize(credentials = {})
|
|
20
|
+
@access_token = credentials[:access_token]
|
|
21
|
+
@refresh_token = credentials[:refresh_token]
|
|
22
|
+
@expires_at = credentials[:expires_at]
|
|
23
|
+
@invalid_at = credentials[:invalid_at]
|
|
24
|
+
@account_id = credentials[:account_id]
|
|
25
|
+
@store = credentials[:store]
|
|
26
|
+
end
|
|
17
27
|
|
|
18
|
-
|
|
28
|
+
# The credentials as Jobber last gave them, plus the moment a refusal to refresh landed.
|
|
29
|
+
attr_reader :access_token, :refresh_token, :expires_at, :invalid_at
|
|
19
30
|
|
|
20
|
-
#
|
|
21
|
-
|
|
31
|
+
# @return [String, nil] ID of the account these credentials reach.
|
|
32
|
+
attr_accessor :account_id
|
|
33
|
+
|
|
34
|
+
# @return [Company::Business] business the credentials belong to, read from Jobber.
|
|
35
|
+
def business = Company::Business.new node: query(BUSINESS).fetch('account', {})
|
|
36
|
+
|
|
37
|
+
# @return [Jobs] jobs of the business.
|
|
38
|
+
def jobs = Jobs.new account: self
|
|
39
|
+
|
|
40
|
+
# @return [Visits] visits of the business.
|
|
41
|
+
def visits = Visits.new account: self
|
|
42
|
+
|
|
43
|
+
# @return [Technicians] users of the business.
|
|
44
|
+
# @note Needs the Users scope, without which Jobber refuses the query rather than the field.
|
|
45
|
+
def technicians = Technicians.new account: self
|
|
46
|
+
|
|
47
|
+
# @return [Quotes] quotes of the business, which Jobber alone lists.
|
|
48
|
+
def quotes = Quotes.new account: self
|
|
49
|
+
|
|
50
|
+
# @return [Leads] leads of the business.
|
|
51
|
+
def leads = Leads.new account: self
|
|
52
|
+
|
|
53
|
+
# @return [Invoices] invoices of the business, which Jobber alone answers for.
|
|
54
|
+
def invoices = Invoices.new account: self
|
|
22
55
|
end
|
|
23
56
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# What the account class does before there are credentials: sends a Jobber user to authorize
|
|
3
|
+
# the app, and trades the code they come back with for a first set.
|
|
4
|
+
module Authorizing
|
|
5
|
+
# Where a Jobber user authorizes the app.
|
|
6
|
+
AUTHORIZE = 'https://api.getjobber.com/api/oauth/authorize'
|
|
7
|
+
|
|
8
|
+
# @param code [String] code Jobber sent to the redirect URI.
|
|
9
|
+
# @param redirect_uri [String] URI the code came back to.
|
|
10
|
+
# @return [Account] credentials for the account that authorized the app, its ID learned.
|
|
11
|
+
# @raise [Error] where Jobber will not take the code.
|
|
12
|
+
def create(code:, redirect_uri:)
|
|
13
|
+
credentials = post code: code, redirect_uri: redirect_uri, grant_type: 'authorization_code'
|
|
14
|
+
new(credentials).tap { |account| account.account_id = account.business.id }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param redirect_uri [String] where Jobber sends the user back to.
|
|
18
|
+
# @param state [String] what the app recognizes them by when they come back.
|
|
19
|
+
# @return [String] URL a Jobber user authorizes the app on.
|
|
20
|
+
def url_for(redirect_uri:, state:)
|
|
21
|
+
return Jbr.mock.oauth_url if Jbr.mocked?
|
|
22
|
+
|
|
23
|
+
uri = URI AUTHORIZE
|
|
24
|
+
uri.query = URI.encode_www_form redirect_uri: redirect_uri, state: state,
|
|
25
|
+
response_type: 'code', client_id: client_id
|
|
26
|
+
uri.to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] client ID the app is registered with Jobber as.
|
|
30
|
+
def client_id = ENV['JOBBER_CLIENT_ID']
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] client secret the app proves itself to Jobber with.
|
|
33
|
+
def client_secret = ENV['JOBBER_CLIENT_SECRET']
|
|
34
|
+
|
|
35
|
+
# @param params [Hash] grant to exchange: a code, or a refresh token.
|
|
36
|
+
# @return [Hash] tokens Jobber answered, and the moment the access one expires.
|
|
37
|
+
def post(params = {})
|
|
38
|
+
return Mock::Account.post params if Jbr.mocked?
|
|
39
|
+
|
|
40
|
+
Token.post params.merge(client_id: client_id, client_secret: client_secret)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/jbr/booking.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# What a list of visits writes. Jobber files the stop booked to go and look at work as an
|
|
3
|
+
# assessment on the request it is opened with, so one mutation files the lead, the hour and
|
|
4
|
+
# the crew, and answers the stop as Jobber stored it rather than as it was asked for.
|
|
5
|
+
module Booking
|
|
6
|
+
# The mutation that opens a request with an assessment booked on it.
|
|
7
|
+
CREATE = <<~GRAPHQL
|
|
8
|
+
mutation($input: RequestCreateInput!) {
|
|
9
|
+
requestCreate(input: $input) {
|
|
10
|
+
request { id client { id } assessment { id title startAt endAt allDay } }
|
|
11
|
+
userErrors { message }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
GRAPHQL
|
|
15
|
+
|
|
16
|
+
# Books a stop against the client answering to the phone and the property at the address,
|
|
17
|
+
# opening either where Jobber has none. Jobber has no source for a request, so that one is
|
|
18
|
+
# dropped, and it takes the hour in the technician's own words, so `starts_at:` must know
|
|
19
|
+
# the zone it is in: a bare Time names no zone and is refused.
|
|
20
|
+
# @return [Visit] stop as Jobber booked it, naming the lead it was booked against.
|
|
21
|
+
def create(name:, surname:, phone:, email:, address:, description:, notes:, source:,
|
|
22
|
+
starts_at:, ends_at:, technicians:)
|
|
23
|
+
booking = schedule starts_at, ends_at, technicians
|
|
24
|
+
customer = Customers.new(account: @account).find_or_create_by phone: phone, name: name,
|
|
25
|
+
surname: surname, email: email, address: address
|
|
26
|
+
property = Locations.new(account: @account).find_or_create_for customer, address
|
|
27
|
+
booked @account.query(CREATE, variables: { input: {
|
|
28
|
+
clientId: customer.id, propertyId: property, title: description,
|
|
29
|
+
assessment: { instructions: notes, schedule: booking },
|
|
30
|
+
} })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def booked(output)
|
|
36
|
+
request = output.dig 'requestCreate', 'request'
|
|
37
|
+
lead = { 'id' => request['id'], 'client' => request['client'] }
|
|
38
|
+
Visit.new node: request.fetch('assessment').merge('request' => lead)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def schedule(starts_at, ends_at, technicians)
|
|
42
|
+
{ startAt: moment(starts_at), endAt: (moment(ends_at) if ends_at),
|
|
43
|
+
teamMemberIdsToAssign: technicians.map(&:id) }.compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def moment(at)
|
|
47
|
+
{ date: at.strftime('%Y-%m-%d'), time: at.strftime('%H:%M:%S'), timezone: zone_of(at) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def zone_of(at)
|
|
51
|
+
return at.time_zone.tzinfo.name if at.respond_to? :time_zone
|
|
52
|
+
|
|
53
|
+
raise Error, "Jobber books #{at} in a named zone: hand over a Time.zone moment, not a Time"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The clients on a Jobber account, reached by phone and opened where none answers to it.
|
|
3
|
+
class Customers < Reader
|
|
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 { #{Location::ADDRESS} } } } }
|
|
9
|
+
} }
|
|
10
|
+
}
|
|
11
|
+
GRAPHQL
|
|
12
|
+
|
|
13
|
+
# The mutation that opens a client, with a first property where an address is given.
|
|
14
|
+
CREATE = <<~GRAPHQL
|
|
15
|
+
mutation($input: ClientCreateInput!) {
|
|
16
|
+
clientCreate(input: $input) {
|
|
17
|
+
client { id clientProperties(first: 1) { nodes { id address { #{Location::ADDRESS} } } } }
|
|
18
|
+
userErrors { message }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
GRAPHQL
|
|
22
|
+
|
|
23
|
+
# Reach the client answering to a number, opening one with the rest where Jobber has none.
|
|
24
|
+
# @param phone [String] number to match on, and to file a new client under.
|
|
25
|
+
# @param name [String] what to call them.
|
|
26
|
+
# @param surname [String, nil] their surname.
|
|
27
|
+
# @param email [String, nil] address they are written to.
|
|
28
|
+
# @param address [Hash] any of :street, :city, :state and :zip, the first place on file.
|
|
29
|
+
# @return [Customer] the client, with the places on their file.
|
|
30
|
+
def find_or_create_by(phone:, name:, surname:, email:, address:)
|
|
31
|
+
find_by(phone) || create(phone:, name:, surname:, email:, address:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def find_by(phone)
|
|
37
|
+
output = @account.query LOOKUP, variables: { searchTerm: phone }
|
|
38
|
+
recent = output.dig('clientPhones', 'nodes').to_a.max_by do |node|
|
|
39
|
+
node.dig('client', 'updatedAt') || ''
|
|
40
|
+
end
|
|
41
|
+
Customer.new node: recent['client'] if recent
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create(phone:, name:, surname:, email:, address:)
|
|
45
|
+
input = { firstName: name, lastName: surname,
|
|
46
|
+
phones: [ { number: phone, primary: true } ],
|
|
47
|
+
emails: ([ { address: email, primary: true } ] if email.present?),
|
|
48
|
+
properties: ([ { address: Locations.address_from(address) } ] if address.present?),
|
|
49
|
+
}.compact
|
|
50
|
+
output = @account.query CREATE, variables: { input: input }
|
|
51
|
+
Customer.new node: output.dig('clientCreate', 'client')
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The invoices on a Jobber account, each reached by the ID Jobber files it under.
|
|
3
|
+
class Invoices < Reader
|
|
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 jobs { nodes { id completedAt } } }
|
|
8
|
+
}
|
|
9
|
+
GRAPHQL
|
|
10
|
+
|
|
11
|
+
# @param id [String] Jobber ID of the invoice.
|
|
12
|
+
# @return [Invoice, nil] nil when Jobber has no invoice under that ID, or holds it as a draft.
|
|
13
|
+
def find(id)
|
|
14
|
+
node = @account.query(FIND, variables: { id: id })['invoice']
|
|
15
|
+
Invoice.new node: node if node && node['invoiceStatus'] != 'draft'
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# The jobs on a Jobber account, oldest first, walked a page at a time.
|
|
3
|
-
class Jobs <
|
|
3
|
+
class Jobs < Collection
|
|
4
4
|
include Includable, Listable
|
|
5
5
|
|
|
6
|
-
# What a job answers with wherever one is read, before anything it was asked to bring
|
|
7
|
-
#
|
|
8
|
-
FIELDS = 'id title instructions
|
|
6
|
+
# What a job answers with wherever one is read, before anything it was asked to bring back
|
|
7
|
+
# with it. Written by hand where the vocabulary's keys would not do: the quote nests.
|
|
8
|
+
FIELDS = 'id title instructions total createdAt startAt completedAt ' \
|
|
9
9
|
'quote { id amounts { total } }'
|
|
10
10
|
|
|
11
11
|
# Shadows Enumerable#find on purpose, the way Active Record does: a job is reached by the
|
|
12
12
|
# ID Jobber files it under, not by asking every job on the account whether it is the one.
|
|
13
|
-
# @param id [String]
|
|
13
|
+
# @param id [String] Jobber ID of the job.
|
|
14
14
|
# @return [Job, nil] nil when Jobber has no job under that ID.
|
|
15
15
|
def find(id)
|
|
16
|
-
node = @
|
|
16
|
+
node = @account.query(one, variables: { id: id })['job']
|
|
17
17
|
Job.new node: node if node
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
-
def page = paged
|
|
22
|
+
def page = paged row(FIELDS), PAGE
|
|
23
23
|
|
|
24
24
|
def one
|
|
25
25
|
<<~GRAPHQL
|
|
26
26
|
query($id: EncodedId!) {
|
|
27
|
-
job(id: $id) { #{FIELDS}
|
|
27
|
+
job(id: $id) { #{row FIELDS} }
|
|
28
28
|
}
|
|
29
29
|
GRAPHQL
|
|
30
30
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The requests on a Jobber account's board: work somebody asked the business for.
|
|
3
|
+
class Leads < Company::Leads
|
|
4
|
+
# The mutation that opens a request against a client and a property.
|
|
5
|
+
CREATE = <<~GRAPHQL
|
|
6
|
+
mutation($input: RequestCreateInput!) {
|
|
7
|
+
requestCreate(input: $input) { request { id client { id } } userErrors { message } }
|
|
8
|
+
}
|
|
9
|
+
GRAPHQL
|
|
10
|
+
|
|
11
|
+
# @param account [Account] credentials to reach Jobber with.
|
|
12
|
+
def initialize(account:)
|
|
13
|
+
@account = account
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Files a request against the client answering to the phone and the property at the address,
|
|
17
|
+
# opening either where Jobber has none. The description is the request's title and the
|
|
18
|
+
# notes its instructions; Jobber has no source for a request, so that one is dropped.
|
|
19
|
+
# @return [Lead] the request, and the client it was opened against.
|
|
20
|
+
def create(name:, surname:, phone:, email:, address:, description:, notes:, source:)
|
|
21
|
+
customer = Customers.new(account: @account).find_or_create_by phone: phone,
|
|
22
|
+
name: name, surname: surname, email: email, address: address
|
|
23
|
+
location_id = Locations.new(account: @account).find_or_create_for customer, address
|
|
24
|
+
input = { clientId: customer.id, propertyId: location_id, title: description,
|
|
25
|
+
assessment: { instructions: notes }, }
|
|
26
|
+
output = @account.query CREATE, variables: { input: input }
|
|
27
|
+
Lead.new node: output.dig('requestCreate', 'request')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The properties on a client's file, and the one a request is opened at.
|
|
3
|
+
class Locations < Reader
|
|
4
|
+
# The mutation that adds a property to a client already on file.
|
|
5
|
+
CREATE = <<~GRAPHQL
|
|
6
|
+
mutation($clientId: EncodedId!, $input: PropertyCreateInput!) {
|
|
7
|
+
propertyCreate(clientId: $clientId, input: $input) {
|
|
8
|
+
properties { id }
|
|
9
|
+
userErrors { message }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
GRAPHQL
|
|
13
|
+
|
|
14
|
+
# What Jobber calls each address field, against what a caller passes.
|
|
15
|
+
FIELDS = { street1: :street, city: :city, province: :state, postalCode: :zip }
|
|
16
|
+
|
|
17
|
+
# The address as Jobber takes it, without the fields a caller left blank.
|
|
18
|
+
# @param fields [Hash] any of :street, :city, :state and :zip.
|
|
19
|
+
# @return [Hash] address, by Jobber's names.
|
|
20
|
+
def self.address_from(fields)
|
|
21
|
+
FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.compact_blank
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Reach the property at an address on a client's file, adding one where none matches.
|
|
25
|
+
# @param customer [Customer] whose file, with the places already on it.
|
|
26
|
+
# @param address [Hash] any of :street, :city, :state and :zip.
|
|
27
|
+
# @return [String, nil] ID of the property, or nil where Jobber declined to add one.
|
|
28
|
+
def find_or_create_for(customer, address)
|
|
29
|
+
wanted = self.class.address_from address
|
|
30
|
+
match = customer.locations.find { |location| same_address? wanted, location }
|
|
31
|
+
match ? match.id : create(customer.id, wanted)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def create(client_id, address)
|
|
37
|
+
output = @account.query CREATE, variables: {
|
|
38
|
+
clientId: client_id, input: { properties: [ { address: address } ] },
|
|
39
|
+
}
|
|
40
|
+
output.dig 'propertyCreate', 'properties', 0, 'id'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def same_address?(wanted, location)
|
|
44
|
+
wanted[:street1] == location.street && wanted[:postalCode] == location.zip
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The quotes on a Jobber account, each reached by the ID Jobber files it under.
|
|
3
|
+
class Quotes < Reader
|
|
4
|
+
# The query that reads one quote, what it comes to, and the request it came from.
|
|
5
|
+
FIND = <<~GRAPHQL
|
|
6
|
+
query($id: EncodedId!) {
|
|
7
|
+
quote(id: $id) { id amounts { total } request { id } }
|
|
8
|
+
}
|
|
9
|
+
GRAPHQL
|
|
10
|
+
|
|
11
|
+
# @param id [String] Jobber ID of the quote.
|
|
12
|
+
# @return [Quote, nil] nil when Jobber has no quote under that ID.
|
|
13
|
+
def find(id)
|
|
14
|
+
node = @account.query(FIND, variables: { id: id })['quote']
|
|
15
|
+
Quote.new node: node if node
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The users on a Jobber account, walked a page at a time. Reading one at all needs the Users
|
|
3
|
+
# scope; narrowing a list to one does not.
|
|
4
|
+
class Technicians < Collection
|
|
5
|
+
include Listable
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def page = paged Technician::FIELDS, PAGE
|
|
10
|
+
|
|
11
|
+
def field = 'users'
|
|
12
|
+
|
|
13
|
+
def filtered = 'UsersFilterAttributes'
|
|
14
|
+
|
|
15
|
+
def item(node) = Technician.new node: node
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The visits on a Jobber account: the stops of its jobs, and the assessments booked to go and
|
|
3
|
+
# look at work before there is a job. Jobber files both as scheduled items and refuses to list
|
|
4
|
+
# one without a window, so a list nothing narrowed has none to give and says so.
|
|
5
|
+
class Visits < Company::Visits
|
|
6
|
+
include Reading, Scheduled, Includable, Listable, Booking
|
|
7
|
+
|
|
8
|
+
# How far an open end reaches. Jobber's window takes two moments and no nil, so a caller
|
|
9
|
+
# who named no end gets a year of one, which is a schedule rather than an archive.
|
|
10
|
+
HORIZON = 1.year
|
|
11
|
+
|
|
12
|
+
# @param from [Time, nil] the moment the window opens, or nothing for as far back as there is.
|
|
13
|
+
# @param to [Time, nil] the moment the window closes, or nothing for as far ahead as there is.
|
|
14
|
+
# @return [Visits] the same list, narrowed to what the window holds.
|
|
15
|
+
def between(from, to)
|
|
16
|
+
now = Time.now
|
|
17
|
+
narrowed occursWithin: { startAt: (from || now - HORIZON).iso8601,
|
|
18
|
+
endAt: (to || now + HORIZON).iso8601 },
|
|
19
|
+
includeUnassigned: true, includeUnscheduled: false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Jobber narrows a schedule by who is on it, so the technician joins the window in the one
|
|
23
|
+
# filter and nobody else's work is answered, paged or paid for.
|
|
24
|
+
# @param technician [Company::Technician] whoever the work is booked for.
|
|
25
|
+
# @return [Visits] the same list, narrowed to what they are booked for.
|
|
26
|
+
# @note Needs no Users scope: Jobber narrows, and no user is selected to do it.
|
|
27
|
+
def assigned_to(technician) = narrowed(assignedTo: [ technician.id ])
|
|
28
|
+
|
|
29
|
+
# @return [Visits] the same list, narrowed to the stops of jobs, by Jobber rather than here.
|
|
30
|
+
def for_jobs = narrowed(scheduleItemType: 'VISIT')
|
|
31
|
+
|
|
32
|
+
# @return [Visits] the same list, narrowed to the assessments, by Jobber rather than here.
|
|
33
|
+
def for_leads = narrowed(scheduleItemType: 'ASSESSMENT')
|
|
34
|
+
|
|
35
|
+
# @yield [Visit] each visit in the window, oldest first.
|
|
36
|
+
def each(&)
|
|
37
|
+
windowed
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Array<String>] every ID in the window, every page of them read.
|
|
42
|
+
def ids
|
|
43
|
+
windowed
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def windowed
|
|
50
|
+
return if @filter&.key? :occursWithin
|
|
51
|
+
|
|
52
|
+
raise Error, 'A Jobber schedule is read by the window: ask between, upcoming or past'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def page = paged row(fields), PAGE
|
|
56
|
+
|
|
57
|
+
def field = 'scheduledItems'
|
|
58
|
+
|
|
59
|
+
def filtered = 'ScheduledItemsFilterAttributes!'
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/jbr/error.rb
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# Jobber refusing a query over what it costs rather than over anything about the query. The
|
|
3
|
+
# bucket it is priced against refills, so the same question asked later is answered; the
|
|
4
|
+
# numbers it was refused with stay in the message.
|
|
5
|
+
Throttled = Class.new Company::Throttled
|
|
6
|
+
end
|
data/lib/jbr/includable.rb
CHANGED
|
@@ -2,26 +2,28 @@ module Jbr
|
|
|
2
2
|
# Extends a list of records with the chaining that says what to bring back beside them.
|
|
3
3
|
# Nothing extra comes back unasked: a page costs what it carries.
|
|
4
4
|
module Includable
|
|
5
|
-
# @param names [Array<Symbol, Hash>] :
|
|
6
|
-
#
|
|
7
|
-
# @return [
|
|
5
|
+
# @param names [Array<Symbol, Hash>] :lines, :technicians, :location, or location: :customer
|
|
6
|
+
# for whose place it is.
|
|
7
|
+
# @return [Collection] the same list, asking Jobber for those too.
|
|
8
|
+
# @note :technicians needs the Users scope, which refuses the whole query where it is unticked.
|
|
8
9
|
def includes(*names)
|
|
9
10
|
named = names.each_with_object({}) do |name, all|
|
|
10
11
|
name.is_a?(Hash) ? all.merge!(name) : all[name] = nil
|
|
11
12
|
end
|
|
12
|
-
self.class.new
|
|
13
|
+
self.class.new account: @account, includes: @includes.merge(named), filter: @filter
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
private
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
def row(fields) = [ fields, selections ].compact_blank.join ' '
|
|
19
|
+
|
|
18
20
|
def selections = @includes.map { |name, nested| selection_of name, nested }.join ' '
|
|
19
21
|
|
|
20
22
|
def selection_of(name, nested)
|
|
21
23
|
case name
|
|
22
|
-
when :
|
|
23
|
-
when :
|
|
24
|
-
when :
|
|
24
|
+
when :lines then Line::SELECTION
|
|
25
|
+
when :technicians then Technician::SELECTION
|
|
26
|
+
when :location then Location.selection customer: nested == :customer
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
end
|