jbr 3.13.0 → 4.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 +51 -0
- data/README.md +154 -167
- data/lib/graphql/client.rb +2 -6
- data/lib/graphql/throttled.rb +0 -9
- data/lib/jbr/account.rb +44 -15
- data/lib/jbr/authorizing.rb +43 -0
- data/lib/jbr/collection.rb +14 -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} +6 -6
- 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/{visits.rb → collections/visits.rb} +8 -8
- data/lib/jbr/error.rb +1 -1
- data/lib/jbr/errors/throttled.rb +6 -0
- data/lib/jbr/includable.rb +6 -8
- data/lib/jbr/listable.rb +12 -34
- data/lib/jbr/mock/account.rb +25 -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/visit.rb +3 -19
- data/lib/jbr/mock/visits.rb +9 -5
- data/lib/jbr/mock.rb +16 -3
- data/lib/jbr/phone.rb +5 -8
- data/lib/jbr/querying.rb +57 -0
- data/lib/jbr/reader.rb +9 -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/visit.rb +17 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr.rb +37 -46
- metadata +39 -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/{refused.rb → errors/refused.rb} +0 -0
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,52 @@
|
|
|
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 [Quotes] quotes of the business, which Jobber alone lists.
|
|
44
|
+
def quotes = Quotes.new account: self
|
|
45
|
+
|
|
46
|
+
# @return [Leads] leads of the business.
|
|
47
|
+
def leads = Leads.new account: self
|
|
48
|
+
|
|
49
|
+
# @return [Invoices] invoices of the business, which Jobber alone answers for.
|
|
50
|
+
def invoices = Invoices.new account: self
|
|
22
51
|
end
|
|
23
52
|
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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# A list of records read through the credentials, with what it was asked to bring back beside
|
|
3
|
+
# each record and what it was narrowed to.
|
|
4
|
+
class Collection < Company::Collection
|
|
5
|
+
# @param account [Account] credentials to reach Jobber with.
|
|
6
|
+
# @param includes [Hash] what to bring back beside each record, by name.
|
|
7
|
+
# @param filter [Hash, nil] what the list was narrowed to, in the shape Jobber filters by.
|
|
8
|
+
def initialize(account:, includes: {}, filter: nil)
|
|
9
|
+
@account = account
|
|
10
|
+
@includes = includes
|
|
11
|
+
@filter = filter
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
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,19 +1,19 @@
|
|
|
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
|
|
|
@@ -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
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# The visits on a Jobber account, oldest first, walked a page at a time.
|
|
3
|
-
class Visits <
|
|
4
|
-
include
|
|
3
|
+
class Visits < Collection
|
|
4
|
+
include Listable
|
|
5
5
|
|
|
6
|
-
# What a visit answers with
|
|
7
|
-
FIELDS = '
|
|
6
|
+
# What a visit answers with: the keys the vocabulary reads, and the job it belongs to.
|
|
7
|
+
FIELDS = "#{Visit.node_keys.join ' '} job { id }"
|
|
8
8
|
|
|
9
9
|
# Shadows Enumerable#find on purpose, the way jobs do: a visit is reached by the ID Jobber
|
|
10
10
|
# files it under, not by asking every visit on the account whether it is the one.
|
|
11
|
-
# @param id [String]
|
|
11
|
+
# @param id [String] Jobber ID of the visit.
|
|
12
12
|
# @return [Visit, nil] nil when Jobber has no visit under that ID.
|
|
13
13
|
def find(id)
|
|
14
|
-
node = @
|
|
14
|
+
node = @account.query(one, variables: { id: id })['visit']
|
|
15
15
|
Visit.new node: node if node
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
|
-
def page = paged
|
|
20
|
+
def page = paged FIELDS, PAGE
|
|
21
21
|
|
|
22
22
|
def one
|
|
23
23
|
<<~GRAPHQL
|
|
24
24
|
query($id: EncodedId!) {
|
|
25
|
-
visit(id: $id) { #{FIELDS}
|
|
25
|
+
visit(id: $id) { #{FIELDS} }
|
|
26
26
|
}
|
|
27
27
|
GRAPHQL
|
|
28
28
|
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,24 @@ 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, :location, or location: :customer for whose
|
|
6
|
+
# place it is.
|
|
7
|
+
# @return [Collection] the same list, asking Jobber for those too.
|
|
8
8
|
def includes(*names)
|
|
9
9
|
named = names.each_with_object({}) do |name, all|
|
|
10
10
|
name.is_a?(Hash) ? all.merge!(name) : all[name] = nil
|
|
11
11
|
end
|
|
12
|
-
self.class.new
|
|
12
|
+
self.class.new account: @account, includes: @includes.merge(named), filter: @filter
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
|
-
# What the includes ask Jobber for, in its own words.
|
|
18
17
|
def selections = @includes.map { |name, nested| selection_of name, nested }.join ' '
|
|
19
18
|
|
|
20
19
|
def selection_of(name, nested)
|
|
21
20
|
case name
|
|
22
|
-
when :
|
|
23
|
-
when :
|
|
24
|
-
when :property then Properted.selection client: nested == :client
|
|
21
|
+
when :lines then Line::SELECTION
|
|
22
|
+
when :location then Location.selection customer: nested == :customer
|
|
25
23
|
end
|
|
26
24
|
end
|
|
27
25
|
end
|
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,42 +17,24 @@ 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
|
-
|
|
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)
|
|
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)
|
|
26
|
+
bounds = { after: from&.iso8601, before: to&.iso8601 }.compact
|
|
27
|
+
self.class.new account: @account, includes: @includes, filter: { startAt: bounds }
|
|
36
28
|
end
|
|
37
29
|
|
|
38
30
|
# The ID Jobber files each record under, and nothing else about it: the cheapest question
|
|
39
31
|
# an account can be walked with, and the one to ask where every record is then read on its
|
|
40
|
-
# own through
|
|
32
|
+
# own through `find`.
|
|
41
33
|
# @return [Array<String>] every ID in the list, every page of them read.
|
|
42
34
|
def ids = walk(ids_page).map(&:id)
|
|
43
35
|
|
|
44
36
|
private
|
|
45
37
|
|
|
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 }
|
|
52
|
-
end
|
|
53
|
-
|
|
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
38
|
def scheduled?(at)
|
|
59
39
|
return true unless @filter
|
|
60
40
|
|
|
@@ -64,13 +44,11 @@ module Jbr
|
|
|
64
44
|
(after.nil? || at >= Time.iso8601(after)) && (before.nil? || at <= Time.iso8601(before))
|
|
65
45
|
end
|
|
66
46
|
|
|
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
47
|
def walk(statement)
|
|
70
48
|
Enumerator.new do |yielder|
|
|
71
49
|
after = nil
|
|
72
50
|
loop do
|
|
73
|
-
answered = @
|
|
51
|
+
answered = @account.query statement, variables: { after: after, filter: @filter }.compact
|
|
74
52
|
current = answered.fetch field, {}
|
|
75
53
|
current.fetch('nodes', []).each { |node| yielder << item(node) }
|
|
76
54
|
break unless current.dig 'pageInfo', 'hasNextPage'
|
data/lib/jbr/mock/account.rb
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
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::Quotes] the one quote the app named.
|
|
15
|
+
def quotes = Mock::Quotes.new
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
# @return [Mock::Leads] the one lead the app named.
|
|
18
|
+
def leads = Mock::Leads.new
|
|
19
|
+
|
|
20
|
+
# @return [Mock::Invoices] the one invoice the app named.
|
|
21
|
+
def invoices = Mock::Invoices.new
|
|
22
|
+
|
|
23
|
+
# Revoking a mocked token asks nobody.
|
|
24
|
+
def delete; end
|
|
25
|
+
|
|
26
|
+
# @return [Hash] canned credentials, unless the app asked for a refusal.
|
|
27
|
+
def self.post(_)
|
|
28
|
+
raise Error, Jbr.mock.oauth_error if Jbr.mock.oauth_error
|
|
29
|
+
|
|
30
|
+
{ access_token: 'mock-token', refresh_token: 'mock-token', expires_at: Time.now + 3600 }
|
|
31
|
+
end
|
|
14
32
|
end
|
|
15
33
|
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
|