jbr 3.12.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 +59 -0
- data/README.md +155 -165
- 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 -36
- data/lib/jbr/mock/client.rb +0 -17
- data/lib/jbr/mock/job.rb +0 -31
- data/lib/jbr/mock/line_item.rb +0 -10
- 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/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,36 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# One line of the work a job is made of: how many of a thing, and what it is called.
|
|
3
|
-
class LineItem < Resource
|
|
4
|
-
# What Jobber calls each field of a line.
|
|
5
|
-
FIELDS = %w[quantity name]
|
|
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 { #{FIELDS.join ' '} } }"
|
|
14
|
-
|
|
15
|
-
# @param nodes [Array<Hash>, nil] the lines as Jobber answered them, if it answered any.
|
|
16
|
-
# @return [Array<LineItem>] one per line, in the order Jobber holds them.
|
|
17
|
-
def self.from(nodes) = nodes.to_a.map { |node| new node: node }
|
|
18
|
-
|
|
19
|
-
# @return [Integer, Float, nil] how many of it the job is for.
|
|
20
|
-
def quantity = whole @node['quantity']
|
|
21
|
-
|
|
22
|
-
# @return [String, nil] what the line is called.
|
|
23
|
-
def name = @node['name']
|
|
24
|
-
|
|
25
|
-
# @return [String] how many of what: `3 Bathroom Faucet Installation`, and the name alone
|
|
26
|
-
# where Jobber holds no quantity for the line.
|
|
27
|
-
def to_s = [ quantity, name ].compact.join ' '
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
# Jobber answers every quantity as a Float, and a whole one reads as an Integer:
|
|
32
|
-
# `3 Faucets` rather than `3.0 Faucets`. A fraction keeps its point — `3.5 Faucets` —
|
|
33
|
-
# since rounding it would lie about what was billed.
|
|
34
|
-
def whole(number) = number && ((number % 1).zero? ? number.to_i : number)
|
|
35
|
-
end
|
|
36
|
-
end
|
data/lib/jbr/mock/client.rb
DELETED
|
@@ -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
|
data/lib/jbr/mock/job.rb
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# A job that reads from {Jbr.mock} instead of Jobber.
|
|
3
|
-
class Mock::Job < Job
|
|
4
|
-
# @return [Object, nil] the values the app asked for.
|
|
5
|
-
def id = @node[:id]
|
|
6
|
-
|
|
7
|
-
def title = @node[:title]
|
|
8
|
-
|
|
9
|
-
def instructions = @node[:instructions]
|
|
10
|
-
|
|
11
|
-
def status = @node[:status]
|
|
12
|
-
|
|
13
|
-
def quote_id = @node[:quote_id]
|
|
14
|
-
|
|
15
|
-
def total = @node[:total]
|
|
16
|
-
|
|
17
|
-
def quote_total = @node[:quote_total]
|
|
18
|
-
|
|
19
|
-
def created_at = @node[:created_at]
|
|
20
|
-
|
|
21
|
-
def client = Mock::Client.new(node: @node.fetch(:client, {}))
|
|
22
|
-
|
|
23
|
-
def property = Mock::Property.new(node: @node.fetch(:property, {}))
|
|
24
|
-
|
|
25
|
-
def line_items = Mock::LineItem.from(@node.fetch(:line_items, []))
|
|
26
|
-
|
|
27
|
-
def scheduled_at = @node[:scheduled_at]
|
|
28
|
-
|
|
29
|
-
def completed_at = @node[:completed_at]
|
|
30
|
-
end
|
|
31
|
-
end
|
data/lib/jbr/mock/line_item.rb
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# A line of a job that reads from {Jbr.mock} instead of Jobber.
|
|
3
|
-
class Mock::LineItem < LineItem
|
|
4
|
-
# @return [Object, nil] the values the app asked for. The quantity is still read whole,
|
|
5
|
-
# so an app that mocks `3.0` of a thing sees the `3` a page would show.
|
|
6
|
-
def quantity = whole @node[:quantity]
|
|
7
|
-
|
|
8
|
-
def name = @node[:name]
|
|
9
|
-
end
|
|
10
|
-
end
|
data/lib/jbr/mock/oauth.rb
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# Credentials that answer from {Jbr.mock} instead of Jobber.
|
|
3
|
-
class Mock::OAuth < OAuth
|
|
4
|
-
# The mocked resources these credentials read and write.
|
|
5
|
-
def invoices = Mock::Invoice.new(oauth: self)
|
|
6
|
-
def jobs = Mock::Jobs.new(oauth: self)
|
|
7
|
-
def quotes = Mock::Quote.new(oauth: self)
|
|
8
|
-
def requests = Mock::Request.new(oauth: self)
|
|
9
|
-
def account = Mock::Account.new oauth: self
|
|
10
|
-
def visits = Mock::Visits.new(oauth: self)
|
|
11
|
-
|
|
12
|
-
# Revoking a mocked token asks nobody.
|
|
13
|
-
def delete; end
|
|
14
|
-
|
|
15
|
-
# @return [Hash] canned credentials, unless the app asked for a refusal.
|
|
16
|
-
def self.post(_)
|
|
17
|
-
raise Error, Jbr.mock.oauth_error if Jbr.mock.oauth_error
|
|
18
|
-
|
|
19
|
-
{ access_token: 'mock-token', refresh_token: 'mock-token', expires_at: (Time.now + 3600) }
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
data/lib/jbr/mock/property.rb
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# A property that reads from {Jbr.mock} instead of Jobber.
|
|
3
|
-
class Mock::Property < Property
|
|
4
|
-
# @return [Object, nil] the values the app asked for.
|
|
5
|
-
def id = @node[:id]
|
|
6
|
-
|
|
7
|
-
def street = @node[:street]
|
|
8
|
-
|
|
9
|
-
def city = @node[:city]
|
|
10
|
-
|
|
11
|
-
def zip = @node[:zip]
|
|
12
|
-
|
|
13
|
-
def latitude = @node[:latitude]
|
|
14
|
-
|
|
15
|
-
def longitude = @node[:longitude]
|
|
16
|
-
|
|
17
|
-
def client = Mock::Client.new(node: @node.fetch(:client, {}))
|
|
18
|
-
end
|
|
19
|
-
end
|
data/lib/jbr/mock/request.rb
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# A request that records what {Jbr.mock} was told to answer.
|
|
3
|
-
class Mock::Request < Request
|
|
4
|
-
# @return [Mock::Request] itself, carrying the mocked IDs.
|
|
5
|
-
def create(_)
|
|
6
|
-
@id = Jbr.mock.request[:id]
|
|
7
|
-
@client_id = Jbr.mock.request[:client_id]
|
|
8
|
-
|
|
9
|
-
self
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
end
|
data/lib/jbr/mock/url.rb
DELETED
data/lib/jbr/mocking.rb
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# The switch an app under test throws to answer Jobber without a network. Touching
|
|
3
|
-
# {#mock} once turns every entry point below over to its Mock counterpart.
|
|
4
|
-
module Mocking
|
|
5
|
-
# @return [Mock] the answers this process gives, created on first use.
|
|
6
|
-
def mock
|
|
7
|
-
@mock ||= Jbr::Mock.new
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
# @param params [Hash] the +code+ and +redirect_uri+ to exchange.
|
|
11
|
-
# @return [OAuth] credentials for the account that authorized the app.
|
|
12
|
-
def create_oauth(params = {})
|
|
13
|
-
(@mock ? Mock::OAuth : OAuth).create(**params)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# @param params [Hash] the +redirect_uri+ and +state+ to come back with.
|
|
17
|
-
# @return [String] the URL a Jobber user authorizes the app on.
|
|
18
|
-
def oauth_url_for(params = {})
|
|
19
|
-
(@mock ? Mock::URL : URL).for(**params)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# @param params [Hash] credentials already on file.
|
|
23
|
-
# @return [OAuth] those credentials, ready to read and write with.
|
|
24
|
-
def oauth_for(params = {})
|
|
25
|
-
(@mock ? Mock::OAuth : OAuth).new(params)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
extend Mocking
|
|
30
|
-
end
|
data/lib/jbr/named.rb
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# Extends a record Jobber lets go untitled: a visit, a job, anything somebody may have
|
|
3
|
-
# opened without ever typing a name for it.
|
|
4
|
-
module Named
|
|
5
|
-
# Untitled happens often enough, and something has to stand in for it on a list.
|
|
6
|
-
# @return [String] the title, or the ID Jobber files the record under. Never empty.
|
|
7
|
-
def name = title.presence || id
|
|
8
|
-
end
|
|
9
|
-
end
|
data/lib/jbr/oauth.rb
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# Credentials for one Jobber account, and the gateway to all they read or write.
|
|
3
|
-
class OAuth
|
|
4
|
-
include Refreshing
|
|
5
|
-
|
|
6
|
-
# The mutation that revokes the app on the account.
|
|
7
|
-
DISCONNECT_MUTATION = <<~GRAPHQL
|
|
8
|
-
mutation Disconnect {
|
|
9
|
-
appDisconnect {
|
|
10
|
-
app { name author }
|
|
11
|
-
userErrors { message }
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
GRAPHQL
|
|
15
|
-
|
|
16
|
-
# @param credentials [Hash] the tokens, their expiry, the account, when it went bad, and
|
|
17
|
-
# the `store:` these are kept in, where processes share them. See {Refreshing}.
|
|
18
|
-
def initialize(credentials = {})
|
|
19
|
-
@access_token = credentials[:access_token]
|
|
20
|
-
@refresh_token = credentials[:refresh_token]
|
|
21
|
-
@expires_at = credentials[:expires_at]
|
|
22
|
-
@account_id = credentials[:account_id]
|
|
23
|
-
@invalid_at = credentials[:invalid_at]
|
|
24
|
-
@store = credentials[:store]
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# The credentials as Jobber last gave them, plus the moment a refusal to refresh landed.
|
|
28
|
-
attr_reader :access_token, :refresh_token, :expires_at, :invalid_at
|
|
29
|
-
# @return [String, nil] the account these credentials reach.
|
|
30
|
-
attr_accessor :account_id
|
|
31
|
-
|
|
32
|
-
# The resources these credentials read and write.
|
|
33
|
-
def account = Account.new oauth: self
|
|
34
|
-
def clients = Client.new oauth: self
|
|
35
|
-
def invoices = Invoice.new oauth: self
|
|
36
|
-
def jobs = Jobs.new oauth: self
|
|
37
|
-
def quotes = Quote.new oauth: self
|
|
38
|
-
def requests = Request.new oauth: self
|
|
39
|
-
def visits = Visits.new oauth: self
|
|
40
|
-
|
|
41
|
-
# Run a statement, refreshing a stale token. Nothing here ever sleeps: where Jobber holds
|
|
42
|
-
# the app to a limit it says so, and a caller asking from a background job has a queue that
|
|
43
|
-
# will bring the whole job back later — which is worth more than a worker asleep holding a
|
|
44
|
-
# transaction open.
|
|
45
|
-
# @return [Hash] the data Jobber answered, or empty when the credentials are dead.
|
|
46
|
-
def query(statement, variables: {})
|
|
47
|
-
client.query statement, variables: variables
|
|
48
|
-
rescue GraphQL::Unauthorized
|
|
49
|
-
refresh ? retry : {}
|
|
50
|
-
rescue GraphQL::Throttled => error
|
|
51
|
-
raise Retriable.new(error.message, error.cost)
|
|
52
|
-
rescue GraphQL::Error => error
|
|
53
|
-
# The transport's own class never leaves the gem: a caller told to rescue `Jbr::Error`
|
|
54
|
-
# was not catching a throttle, a 500 or an unreadable answer, and had its own job blow
|
|
55
|
-
# up instead of hearing that Jobber would not answer.
|
|
56
|
-
raise Error, error.message
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Delete a token. If the token is invalid, do nothing.
|
|
60
|
-
def delete
|
|
61
|
-
client.query DISCONNECT_MUTATION
|
|
62
|
-
rescue GraphQL::Unauthorized => e
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Exchange an authorization code for credentials, then learn their account.
|
|
66
|
-
# @param code [String] the code Jobber sent to the redirect URI.
|
|
67
|
-
# @param redirect_uri [String] the URI the code came back to.
|
|
68
|
-
# @return [OAuth] the new credentials.
|
|
69
|
-
def self.create(code:, redirect_uri:)
|
|
70
|
-
credentials = post code: code, redirect_uri: redirect_uri, grant_type: 'authorization_code'
|
|
71
|
-
new(credentials).tap { |oauth| oauth.account_id = oauth.account.id }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# @return [String, nil] The client ID to interact with the API.
|
|
75
|
-
def self.client_id = ENV['JOBBER_CLIENT_ID']
|
|
76
|
-
|
|
77
|
-
# @return [String, nil] The client secret to interact with the API.
|
|
78
|
-
def self.client_secret = ENV['JOBBER_CLIENT_SECRET']
|
|
79
|
-
|
|
80
|
-
# Exchange a code or a refresh token for credentials.
|
|
81
|
-
def self.post(params = {})
|
|
82
|
-
Token.post params.merge(client_id: client_id, client_secret: client_secret)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
private
|
|
86
|
-
|
|
87
|
-
def client
|
|
88
|
-
GraphQL::Client.new endpoint: 'https://api.getjobber.com/api/graphql',
|
|
89
|
-
token: @access_token, headers: headers
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def headers = { 'X-JOBBER-GRAPHQL-VERSION' => '2026-04-22' }
|
|
93
|
-
end
|
|
94
|
-
end
|
data/lib/jbr/properted.rb
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# Extends a record that stands at a property: a visit, a job, anything Jobber files
|
|
3
|
-
# somewhere.
|
|
4
|
-
module Properted
|
|
5
|
-
# What to ask for where a record names the place the work happens at.
|
|
6
|
-
# @param client [Boolean] whether the place's own client comes back with it, which spares
|
|
7
|
-
# a second query for somebody Jobber already knows the place belongs to.
|
|
8
|
-
def self.selection(client: false)
|
|
9
|
-
[ 'property { id address {', Property::SELECTION, '}',
|
|
10
|
-
(Cliental::SELECTION if client), '}',
|
|
11
|
-
].compact.join ' '
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# @return [Property] where the work happens, and who Jobber holds the place for.
|
|
15
|
-
def property = Property.new node: @node.fetch('property', {})
|
|
16
|
-
end
|
|
17
|
-
end
|
data/lib/jbr/property.rb
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# Where the work happens: one address on a client's file.
|
|
3
|
-
class Property < Resource
|
|
4
|
-
include Cliental
|
|
5
|
-
|
|
6
|
-
# The mutation that adds a property to a client already on file.
|
|
7
|
-
CREATE = <<~GRAPHQL
|
|
8
|
-
mutation propertyCreateMutation($clientId: EncodedId!, $input: PropertyCreateInput!) {
|
|
9
|
-
propertyCreate(clientId: $clientId, input: $input) {
|
|
10
|
-
properties { id }
|
|
11
|
-
userErrors { message }
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
GRAPHQL
|
|
15
|
-
|
|
16
|
-
# What Jobber calls each address field, against what a caller passes.
|
|
17
|
-
FIELDS = { street1: :street, city: :city, province: :state, postalCode: :zip }
|
|
18
|
-
|
|
19
|
-
# The two Jobber tucks inside the address and a property carries beside the street.
|
|
20
|
-
COORDINATES = %i[latitude longitude]
|
|
21
|
-
|
|
22
|
-
# The address as Jobber answers it, asked for wherever a property is read.
|
|
23
|
-
SELECTION = "#{FIELDS.keys.join ' '} coordinates { #{COORDINATES.join ' '} }"
|
|
24
|
-
|
|
25
|
-
# Where the place is, in the fields a caller passes to open one.
|
|
26
|
-
# @return [Hash] any of :street, :city, :state, :zip, :latitude and :longitude.
|
|
27
|
-
def address = Property.fields_from @node['address']
|
|
28
|
-
|
|
29
|
-
# @return [String, nil] the street the work happens on.
|
|
30
|
-
def street = address[:street]
|
|
31
|
-
|
|
32
|
-
# @return [String, nil] the town the work happens in.
|
|
33
|
-
def city = address[:city]
|
|
34
|
-
|
|
35
|
-
# @return [String, nil] the postal code the work happens in.
|
|
36
|
-
def zip = address[:zip]
|
|
37
|
-
|
|
38
|
-
# @return [Float, nil] how far north the work happens.
|
|
39
|
-
def latitude = address[:latitude]
|
|
40
|
-
|
|
41
|
-
# @return [Float, nil] how far east the work happens.
|
|
42
|
-
def longitude = address[:longitude]
|
|
43
|
-
|
|
44
|
-
# The fields a match is made on. City and state are written but never matched:
|
|
45
|
-
# Jobber holds whatever was typed, so "NC" and "North Carolina" -- or "Winston Salem"
|
|
46
|
-
# and "Winston-Salem" -- would read as two homes. The ZIP already places the home.
|
|
47
|
-
MATCHED = %i[street1 postalCode]
|
|
48
|
-
|
|
49
|
-
# The address as Jobber takes it, from the fields a caller passes.
|
|
50
|
-
# @param fields [Hash] any of :street, :city, :state and :zip.
|
|
51
|
-
# @return [Hash] the address, without the fields the caller left out.
|
|
52
|
-
def self.address_from(fields = {})
|
|
53
|
-
FIELDS.to_h { |jobber, ours| [ jobber, fields[ours] ] }.compact_blank
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# The fields a caller reads, from the address as Jobber holds it.
|
|
57
|
-
# @param address [Hash, nil] the address Jobber answered, if it answered one.
|
|
58
|
-
# @return [Hash] any of :street, :city, :state, :zip, :latitude and :longitude,
|
|
59
|
-
# without the ones Jobber left out.
|
|
60
|
-
def self.fields_from(address)
|
|
61
|
-
address ||= {}
|
|
62
|
-
coordinates = address['coordinates'] || {}
|
|
63
|
-
FIELDS.to_h { |jobber, ours| [ ours, address[jobber.to_s] ] }.
|
|
64
|
-
merge(COORDINATES.to_h { |ours| [ ours, coordinates[ours.to_s] ] }).compact_blank
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Reach the property at an address, adding one when none of the client's matches.
|
|
68
|
-
# @param client_id [String] the client the property belongs to.
|
|
69
|
-
# @param address [Hash] the fields the work happens at.
|
|
70
|
-
# @param existing [Array<Hash>] the properties already on the client's file.
|
|
71
|
-
# @return [String, nil] the property ID.
|
|
72
|
-
def find_or_create_for(client_id:, address:, existing: [])
|
|
73
|
-
wanted = self.class.address_from address
|
|
74
|
-
match = existing.find { |property| same_address? wanted, property['address'] }
|
|
75
|
-
return match['id'] if match
|
|
76
|
-
|
|
77
|
-
output = @oauth.query CREATE, variables: {
|
|
78
|
-
clientId: client_id, input: { properties: [ { address: wanted } ] },
|
|
79
|
-
}
|
|
80
|
-
(output&.dig('propertyCreate', 'properties')&.first || {})['id']
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
private
|
|
84
|
-
|
|
85
|
-
# Field by field, because Jobber answers every field it was asked for, nil included,
|
|
86
|
-
# while a caller's address carries only what they had -- an absent field and a nil
|
|
87
|
-
# one are the same address. All of {MATCHED} has to agree: a home with no street
|
|
88
|
-
# parsed must not match the one house on the client's file that does have one.
|
|
89
|
-
def same_address?(wanted, address)
|
|
90
|
-
MATCHED.all? { |field| wanted[field] == (address || {})[field.to_s] }
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
data/lib/jbr/quote.rb
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
module Jbr
|
|
2
|
-
# A price a Jobber user sent to their client.
|
|
3
|
-
class Quote < Resource
|
|
4
|
-
# The query that reads one quote and the request it came from.
|
|
5
|
-
FIND = <<~GRAPHQL
|
|
6
|
-
query($id: EncodedId!) {
|
|
7
|
-
quote(id: $id) { id request { id } }
|
|
8
|
-
}
|
|
9
|
-
GRAPHQL
|
|
10
|
-
|
|
11
|
-
# @return [String, nil] the ID of the request the quote answers.
|
|
12
|
-
attr_reader :request_id
|
|
13
|
-
|
|
14
|
-
# @param id [String] the Jobber ID of the quote.
|
|
15
|
-
# @return [Quote, nil] itself, or nil when Jobber has no such quote.
|
|
16
|
-
def find(id)
|
|
17
|
-
output = @oauth.query FIND, variables: { id: id }
|
|
18
|
-
return unless quote = output['quote']
|
|
19
|
-
|
|
20
|
-
@id = quote['id']
|
|
21
|
-
@request_id = quote.dig 'request', 'id'
|
|
22
|
-
self
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|