hcp 1.2.4 → 1.4.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/.yardopts +5 -0
- data/CHANGELOG.md +41 -0
- data/CLAUDE.md +43 -0
- data/README.md +189 -8
- data/lib/hcp/access.rb +12 -0
- data/lib/hcp/answer.rb +35 -0
- data/lib/hcp/concerns/chainable.rb +39 -0
- data/lib/hcp/concerns/keyed.rb +14 -0
- data/lib/hcp/concerns/named.rb +7 -0
- data/lib/hcp/concerns/queryable.rb +41 -0
- data/lib/hcp/concerns/scheduled.rb +16 -0
- data/lib/hcp/concerns/statused.rb +23 -0
- data/lib/hcp/concerns/timestamped.rb +10 -0
- data/lib/hcp/error.rb +1 -0
- data/lib/hcp/errors/not_found.rb +4 -0
- data/lib/hcp/errors/too_many_requests.rb +16 -0
- data/lib/hcp/event.rb +1 -0
- data/lib/hcp/filter.rb +36 -0
- data/lib/hcp/key.rb +24 -0
- data/lib/hcp/lead/pipeline.rb +6 -3
- data/lib/hcp/lead.rb +10 -3
- data/lib/hcp/relation.rb +76 -0
- data/lib/hcp/request.rb +31 -0
- data/lib/hcp/resource.rb +50 -7
- data/lib/hcp/resources/address.rb +24 -0
- data/lib/hcp/resources/booking_window.rb +52 -0
- data/lib/hcp/resources/company.rb +42 -0
- data/lib/hcp/resources/customer.rb +52 -0
- data/lib/hcp/resources/employee.rb +12 -0
- data/lib/hcp/resources/estimate/option.rb +26 -0
- data/lib/hcp/resources/estimate.rb +49 -0
- data/lib/hcp/resources/job/appointment.rb +22 -0
- data/lib/hcp/resources/job/invoice.rb +21 -0
- data/lib/hcp/resources/job.rb +73 -0
- data/lib/hcp/resources/line_item.rb +20 -0
- data/lib/hcp/resources/note.rb +6 -0
- data/lib/hcp/resources/schedule.rb +21 -0
- data/lib/hcp/version.rb +1 -3
- data/lib/hcp.rb +48 -2
- metadata +117 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7eda96bf4ffa8ef77fdb696d95c2f47bed39b593ef075051ae40c5fe5f2e64c8
|
|
4
|
+
data.tar.gz: 52b938c9001e14231015447cc203d7ea72899a4ef982d58aa2f41ce88cf99c66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 68b998e5fcaaf1656ea9fef6d11a8dff91c4a7a46a4f5b87c3d681dac2f99b4a10b4eae8cb2b115c2f272ce100667cfed87c83700fdd778380c2370ea877f8e9
|
|
7
|
+
data.tar.gz: 694e677888b6b317779b07f11f9f8644ac1b8e68fd8643b2a3bd300057cb43c1a696c05d844ed25f4310a0eba465824c33ea459ba73b341f3eeb1378ee3c349c
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [1.4.0] - 2026-08-28
|
|
4
|
+
|
|
5
|
+
- [Feature] Read as a key for one block, on one thread: `Hcp.with_key(key, company_id:)`,
|
|
6
|
+
which hands the block an `Hcp::Access` answering `account`. A process serving several
|
|
7
|
+
accounts held them all on one `Hcp.key`, so two threads could read each other's; a key
|
|
8
|
+
handed to a block is the thread's alone, and the one set before it comes back after.
|
|
9
|
+
|
|
10
|
+
- [Feature] Read the account a key belongs to: `Hcp::Company.current`, taking `company_id:`.
|
|
11
|
+
There is no list of companies and no ID to find one by, so it is `current` rather than
|
|
12
|
+
`find` or `where`. `locations` is the account itself and then every location under it at
|
|
13
|
+
any depth, flat, so it is never empty; each ID is what `company_id:` takes everywhere else.
|
|
14
|
+
|
|
15
|
+
- [Fix] `Hcp::Address#latitude` and `#longitude` answer a `Float` whichever endpoint they were
|
|
16
|
+
read from. Housecall Pro stamps them as numbers under a customer and as strings under the
|
|
17
|
+
company, and the gem documented `Float` while handing back whatever arrived.
|
|
18
|
+
|
|
19
|
+
- [Feature] Read when the account is free to be booked into: `Hcp::BookingWindow.all`, taking
|
|
20
|
+
`starts_at:`, `days:`, `minutes:`, `service_id:`, `price_form_id:` and `employee_ids:`.
|
|
21
|
+
Housecall Pro answers this list whole rather than a page at a time, so it comes back as an
|
|
22
|
+
`Array` rather than as a relation.
|
|
23
|
+
|
|
24
|
+
## [1.3.0] - 2026-08-25
|
|
25
|
+
|
|
26
|
+
- [Feature] Read customers, estimates, jobs and job appointments as an Active Record relation:
|
|
27
|
+
`Hcp::Job.all`, `.where`, `.order`, `.limit`, `.includes`, `.find`, `.count` and `.first`,
|
|
28
|
+
walked lazily a page at a time.
|
|
29
|
+
- [Feature] Set the API key once, with `Hcp.key` or `HCP_KEY`, and pass `company_id:` per call.
|
|
30
|
+
- [Feature] Narrow a list by a range — `where(scheduled_at: ..2.days.ago)` — and refuse a
|
|
31
|
+
condition, an order or an expansion Housecall Pro does not take, which it answers by
|
|
32
|
+
ignoring rather than by refusing.
|
|
33
|
+
- [Feature] Raise `Hcp::NotFound` where Housecall Pro has no such record, and
|
|
34
|
+
`Hcp::TooManyRequests`, carrying `reset_at`, where it refuses one for rate. Both descend
|
|
35
|
+
from `Hcp::Error`, so an existing rescue still catches them.
|
|
36
|
+
- [Fix] `require 'hcp'` defines `Hcp::VERSION`, which until now was only set as a side effect
|
|
37
|
+
of Bundler evaluating the gemspec.
|
|
38
|
+
- [Breaking change] `Hcp::Lead#lead_for`, `#customer_for` and `#uri` are now private, and
|
|
39
|
+
neither `Hcp::Lead` nor `Hcp::Lead::Pipeline` inherits from `Hcp::Resource`, which is now a
|
|
40
|
+
record rather than a holder of credentials. Nothing documented ever called them.
|
|
41
|
+
|
|
1
42
|
## [1.2.4] - 2026-06-17
|
|
2
43
|
|
|
3
44
|
- [Fix] Set event.type when params is nil
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# hcp
|
|
2
|
+
|
|
3
|
+
A Ruby client for the Housecall Pro API.
|
|
4
|
+
|
|
5
|
+
## Follow the coding guidelines
|
|
6
|
+
|
|
7
|
+
https://raw.githubusercontent.com/HouseAccountEng/guidelines/refs/heads/main/STYLE.md
|
|
8
|
+
|
|
9
|
+
Read them before writing, not after review. They are stricter than this code alone suggests: one
|
|
10
|
+
line of comment above every public declaration and none above a private one, no metaprogramming
|
|
11
|
+
except on an explicit instruction, no method that nothing calls, no rescue for an error that has
|
|
12
|
+
never happened, and a cap of 100 lines to a file and 50 Ruby files to a folder — both enforced by
|
|
13
|
+
`bundle exec rake`, which is the gate for everything here.
|
|
14
|
+
|
|
15
|
+
Two rules are easy to miss and expensive to undo: a commit message carries no trailer naming its
|
|
16
|
+
author, and a CHANGELOG entry says which of fix, feature or breaking change it is, because that is
|
|
17
|
+
what picks the version.
|
|
18
|
+
|
|
19
|
+
## Probe the API rather than trust its spec
|
|
20
|
+
|
|
21
|
+
The authoritative OpenAPI spec is at
|
|
22
|
+
|
|
23
|
+
https://stoplight.io/api/v1/projects/housecallpro/housecall-public-api/nodes/reference/housecall.v1.yaml
|
|
24
|
+
|
|
25
|
+
docs.housecallpro.com renders it client-side, so fetching that HTML gets nothing worth reading.
|
|
26
|
+
|
|
27
|
+
**The spec disagrees with the live API.** Send a real request before writing code against a
|
|
28
|
+
documented shape. Six disagreements found so far, each of which would have shipped a bug:
|
|
29
|
+
|
|
30
|
+
- `page_size` is capped at 200. The spec publishes no maximum.
|
|
31
|
+
- `GET /jobs/{id}/line_items` answers `{"object":"list","data":[…]}`, not the documented
|
|
32
|
+
`{url, data}`.
|
|
33
|
+
- A refusal comes back three ways — `{"error":{"message":…}}`, `{"error":"…"}` and `{"message":…}`.
|
|
34
|
+
The spec describes only the first.
|
|
35
|
+
- An unknown filter is **ignored, not refused**, so a typo answers the whole account rather than a
|
|
36
|
+
page of it. This is why the gem checks condition names itself.
|
|
37
|
+
- `GET /company` answers `default_arrival_window` as an integer. The spec publishes it as a string.
|
|
38
|
+
- `GET /company` stamps an address's `latitude` and `longitude` as **strings**, where
|
|
39
|
+
`GET /customers/{id}/addresses` answers the same fields as numbers.
|
|
40
|
+
|
|
41
|
+
A company-scoped key refuses `X-Company-Id` with a 401 on every endpoint, so the header can only
|
|
42
|
+
be exercised with an application key. `GET /company` answers `locations` only to the latter, and
|
|
43
|
+
nests: a location holds locations of its own, several levels deep.
|
data/README.md
CHANGED
|
@@ -1,20 +1,201 @@
|
|
|
1
1
|
# The Housecall Pro API Ruby client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Every method in this gem is one Housecall Pro endpoint. Where Housecall Pro has no endpoint,
|
|
4
|
+
this gem has no method — so a resource answers `update` only where Housecall Pro takes a `PUT`,
|
|
5
|
+
and a job, which it takes none for, answers none.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
This release reads. Writing — beyond the leads that were here before it — comes next.
|
|
8
|
+
|
|
9
|
+
## How to install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
gem install hcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or, in a Gemfile, pinned to the current major:
|
|
6
16
|
|
|
7
17
|
```ruby
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
gem 'hcp', '~> 1.4'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`~> major.minor` means `bundle update` never crosses a breaking change.
|
|
22
|
+
|
|
23
|
+
## The key, and the location
|
|
24
|
+
|
|
25
|
+
Set the key once. Left unset, the gem reads `HCP_KEY` from the environment:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
Hcp.key = ENV['HCP_KEY']
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
A process serving several accounts hands each thread its own key for as long as a block runs,
|
|
32
|
+
and is handed back the account that key opens:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Hcp.with_key(key, company_id: location_id) do |access|
|
|
36
|
+
access.account.name # the location, as `Hcp::Company.current` would read it
|
|
37
|
+
Hcp::Job.limit 10 # every read inside the block is this key's
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Neither thread sees the other's key, and the one set before the block comes back after it.
|
|
42
|
+
|
|
43
|
+
An account with more than one location passes `company_id:` per call, since one process may
|
|
44
|
+
serve several:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
Hcp::Job.find id, company_id: company_id
|
|
48
|
+
Hcp::Job.all(company_id: company_id).where(work_status: :scheduled)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`Hcp::Company.current.locations` is where those IDs come from.
|
|
52
|
+
|
|
53
|
+
## Reading
|
|
54
|
+
|
|
55
|
+
A list is walked lazily, a page read only once the one before it runs out, so asking for three
|
|
56
|
+
records costs one request rather than all of them:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Hcp::Job.all
|
|
60
|
+
Hcp::Job.find id
|
|
61
|
+
Hcp::Job.limit 50
|
|
62
|
+
Hcp::Job.order updated_at: :desc
|
|
63
|
+
Hcp::Job.where scheduled_at: ..2.days.ago
|
|
64
|
+
Hcp::Job.where(customer_id: id).order(created_at: :asc).limit 10
|
|
65
|
+
Hcp::Job.all.count
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A condition written as a range is sent as the two ends Housecall Pro takes, and an end left
|
|
69
|
+
open is left out:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
Hcp::Job.where scheduled_at: 2.days.ago.. # scheduled_start_min
|
|
73
|
+
Hcp::Job.where scheduled_at: ..2.days.ago # scheduled_start_max
|
|
74
|
+
Hcp::Job.where scheduled_at: 1.week.ago..Time.now
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Housecall Pro narrows a list by one set of words and answers with another, so this gem speaks
|
|
78
|
+
the narrower's words on both sides: `where(work_status: :in_progress)` and `job.work_status`
|
|
79
|
+
both say `:in_progress`.
|
|
80
|
+
|
|
81
|
+
Nothing comes back unasked. `includes` asks for what Housecall Pro otherwise leaves out:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
Hcp::Job.includes(:appointments).limit 20
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A condition, an order or an expansion Housecall Pro does not take is refused here rather than
|
|
88
|
+
sent — it answers an unknown condition by ignoring it and handing back the whole account:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Hcp::Job.where bogus: 1
|
|
92
|
+
# => Hcp::Error: bogus is not one of: scheduled_at, ends_at, customer_id, ...
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### What each resource reads
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
job = Hcp::Job.find id
|
|
99
|
+
job.description, job.work_status, job.total_amount, job.invoice_number
|
|
100
|
+
job.customer.name, job.address.city, job.assigned_employees, job.notes, job.tags
|
|
101
|
+
job.schedule.starts_at, job.schedule.time_zone, job.completed_at
|
|
102
|
+
job.appointments, job.line_items, job.invoices
|
|
103
|
+
|
|
104
|
+
customer = Hcp::Customer.where(q: 'Ada').first
|
|
105
|
+
customer.name, customer.email, customer.phone, customer.kind, customer.addresses
|
|
106
|
+
|
|
107
|
+
estimate = Hcp::Estimate.find id
|
|
108
|
+
estimate.estimate_number, estimate.options.map(&:total_amount)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Housecall Pro counts money in cents; this gem reads it in dollars, as a `BigDecimal`.
|
|
112
|
+
|
|
113
|
+
## The company
|
|
114
|
+
|
|
115
|
+
The account the key belongs to. There is no list of companies to narrow and no ID to look one
|
|
116
|
+
up by — a key reads its own account and nothing else — so it is read with `current` rather than
|
|
117
|
+
with `find` or `where`:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
company = Hcp::Company.current
|
|
121
|
+
company.name, company.phone, company.support_email, company.website, company.logo_url
|
|
122
|
+
company.time_zone, company.arrival_window, company.address.city, company.zip_codes
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`arrival_window` is how many minutes wide a customer's window is by default, and `zip_codes`
|
|
126
|
+
are the ones the account will travel to.
|
|
13
127
|
|
|
14
|
-
|
|
128
|
+
`locations` is never empty: it is the account itself, then every location under it at any
|
|
129
|
+
depth, in the order Housecall Pro lists them. A single company is its own one location, and a
|
|
130
|
+
franchise reads flat however deep Housecall Pro nests it:
|
|
15
131
|
|
|
16
132
|
```ruby
|
|
133
|
+
Hcp::Company.current.locations.map(&:name)
|
|
134
|
+
# => ['Acme Handyman HQ', 'Acme Handyman East', 'Acme Handyman of Springfield', …]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Each location's `id` is what `company_id:` takes, here and everywhere else:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Hcp::Company.current company_id: location.id
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Booking windows
|
|
144
|
+
|
|
145
|
+
When the account is free to be booked into, as its Online Booking settings answer it. Housecall
|
|
146
|
+
Pro hands this list back whole rather than a page at a time, so it comes back as an `Array`
|
|
147
|
+
rather than as a relation — there is nothing left to narrow, order or cut afterwards.
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
Hcp::BookingWindow.all
|
|
151
|
+
Hcp::BookingWindow.all starts_at: Date.tomorrow, days: 14
|
|
152
|
+
Hcp::BookingWindow.all employee_ids: [ employee.id ]
|
|
153
|
+
Hcp::BookingWindow.all service_id: id, minutes: 90
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Each window says when it opens, when it closes, and whether it is free:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
free = Hcp::BookingWindow.all(days: 3).select(&:available?)
|
|
160
|
+
free.map { |window| [ window.starts_at, window.ends_at ] }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Left out, `starts_at` is the next day holding a free window and `days` is seven. A window is
|
|
164
|
+
cut to the service's own duration where `service_id` names one, to `minutes` where that is
|
|
165
|
+
given, and to thirty minutes otherwise.
|
|
166
|
+
|
|
167
|
+
## Errors
|
|
168
|
+
|
|
169
|
+
Everything descends from `Hcp::Error`, so one rescue still catches the lot.
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
Hcp::NotFound # Housecall Pro has no record under that ID
|
|
173
|
+
Hcp::TooManyRequests # refused for rate; #reset_at says when it lifts
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Nothing here sleeps. A caller told to come back later has a queue that can bring the whole job
|
|
177
|
+
back, which is worth more than a worker asleep holding a connection open.
|
|
178
|
+
|
|
179
|
+
## Leads
|
|
180
|
+
|
|
181
|
+
Opening a lead, and moving one through the pipeline, are unchanged:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
lead = Hcp::Lead.new key:, company_id:
|
|
185
|
+
lead.create name: 'Ada', phone: '5550000001', email: 'ada@example.com',
|
|
186
|
+
address: { street: '1 Example Street', city: 'Springfield', state: 'CA', zip: '90210' },
|
|
187
|
+
note: 'Very interested in buying', source: 'The Lead Generator'
|
|
188
|
+
|
|
17
189
|
pipeline = Hcp::Lead::Pipeline.new id:, key:, company_id:
|
|
18
190
|
pipeline.update status_name: 'Won'
|
|
19
191
|
```
|
|
20
192
|
|
|
193
|
+
## Webhooks
|
|
194
|
+
|
|
195
|
+
`Hcp::Event` reads a webhook payload, and reaches the network for nothing:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
event = Hcp::Event.new params
|
|
199
|
+
event.type # :job_scheduled
|
|
200
|
+
event.job_id, event.customer_id, event.scheduled_at, event.invoice_amount
|
|
201
|
+
```
|
data/lib/hcp/access.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# What a key opens: the account it belongs to, read as one of its locations.
|
|
3
|
+
class Access
|
|
4
|
+
# @param company_id [String, nil] the location to read as, where the account has several.
|
|
5
|
+
def initialize(company_id: nil)
|
|
6
|
+
@company_id = company_id
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# @return [Company] the account the key belongs to, as the location it is read as.
|
|
10
|
+
def account = Company.current company_id: @company_id
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/hcp/answer.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# What Housecall Pro answered: the body it carries, or the refusal it stands for.
|
|
3
|
+
class Answer
|
|
4
|
+
# The header Housecall Pro names the epoch a rate limit lifts at.
|
|
5
|
+
RESET_HEADER = 'RateLimit-Reset'
|
|
6
|
+
|
|
7
|
+
# @param response [Net::HTTPResponse] what Housecall Pro sent back.
|
|
8
|
+
def initialize(response)
|
|
9
|
+
@response = response
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Hash] the record or page Housecall Pro answered with.
|
|
13
|
+
def body
|
|
14
|
+
case @response
|
|
15
|
+
when Net::HTTPSuccess then parsed
|
|
16
|
+
when Net::HTTPNotFound then raise NotFound, message
|
|
17
|
+
when Net::HTTPTooManyRequests then raise TooManyRequests.new(message, reset_at)
|
|
18
|
+
else raise Error, message
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Housecall Pro writes a refusal three ways: the message under an `error`, the message as
|
|
25
|
+
# the `error`, and the message on its own.
|
|
26
|
+
def message
|
|
27
|
+
error = parsed['error'] || parsed['message']
|
|
28
|
+
error.is_a?(Hash) ? error['message'] : error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_at = (Time.at Integer(@response[RESET_HEADER]) if @response[RESET_HEADER])
|
|
32
|
+
|
|
33
|
+
def parsed = @parsed ||= JSON(@response.body)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a list with the chaining that narrows, orders and cuts it.
|
|
3
|
+
module Chainable
|
|
4
|
+
# A condition Housecall Pro does not take is one it ignores, answering the whole account
|
|
5
|
+
# rather than a page of it, so an unknown one is refused here where it can still be named.
|
|
6
|
+
# @return [Relation] the same list, narrowed by these conditions.
|
|
7
|
+
def where(**conditions)
|
|
8
|
+
only conditions.keys, @type.filters.keys
|
|
9
|
+
narrowed conditions: @conditions.merge(conditions)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Relation] the same list, in this order.
|
|
13
|
+
def order(**sorts)
|
|
14
|
+
only sorts.keys, @type.sorts
|
|
15
|
+
narrowed sorts: sorts
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Relation] the same list, stopping after this many records.
|
|
19
|
+
def limit(count) = narrowed limit: count
|
|
20
|
+
|
|
21
|
+
# @return [Relation] the same list, asking Housecall Pro for these beside each record.
|
|
22
|
+
def includes(*names)
|
|
23
|
+
only names, @type.expands
|
|
24
|
+
narrowed expands: @expands | names
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def only(names, allowed)
|
|
30
|
+
unknown = names - allowed
|
|
31
|
+
raise Error, "#{unknown.first} is not one of: #{allowed.join ', '}" if unknown.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def narrowed(conditions: @conditions, sorts: @sorts, limit: @limit, expands: @expands)
|
|
35
|
+
Relation.new type: @type, path: @path, company_id: @company_id, conditions: conditions,
|
|
36
|
+
sorts: sorts, limit: limit, expands: expands
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a class handed its own key, as everything was before the key became global.
|
|
3
|
+
module Keyed
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def headers
|
|
7
|
+
{
|
|
8
|
+
'Authorization' => "Token #{@key}",
|
|
9
|
+
'Content-Type' => 'application/json',
|
|
10
|
+
'X-Company-Id' => @company_id,
|
|
11
|
+
}.compact
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a record Housecall Pro holds a person's names for.
|
|
3
|
+
module Named
|
|
4
|
+
# @return [String] what they are called, by whichever of their names Housecall Pro holds.
|
|
5
|
+
def name = [ @node['first_name'], @node['last_name'] ].compact_blank.join ' '
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a resource with the entry points that read a list of it, or one record.
|
|
3
|
+
module Queryable
|
|
4
|
+
# @param company_id [String, nil] the location to read as, where the account has several.
|
|
5
|
+
# @return [Relation] every record of this kind the location holds.
|
|
6
|
+
def all(company_id: nil) = Relation.new type: self, company_id: company_id
|
|
7
|
+
|
|
8
|
+
# @return [Relation] the records matching these conditions.
|
|
9
|
+
def where(company_id: nil, **conditions) = all(company_id: company_id).where(**conditions)
|
|
10
|
+
|
|
11
|
+
# @return [Relation] the records in this order.
|
|
12
|
+
def order(company_id: nil, **sorts) = all(company_id: company_id).order(**sorts)
|
|
13
|
+
|
|
14
|
+
# @return [Relation] at most this many records.
|
|
15
|
+
def limit(count, company_id: nil) = all(company_id: company_id).limit(count)
|
|
16
|
+
|
|
17
|
+
# @return [Relation] the records, with these brought back beside each of them.
|
|
18
|
+
def includes(*names, company_id: nil) = all(company_id: company_id).includes(*names)
|
|
19
|
+
|
|
20
|
+
# @return [Integer] how many records of this kind the location holds.
|
|
21
|
+
def count(company_id: nil) = all(company_id: company_id).count
|
|
22
|
+
|
|
23
|
+
# @return [Resource, nil] the first record, or nil where the location holds none.
|
|
24
|
+
def first(company_id: nil) = all(company_id: company_id).first
|
|
25
|
+
|
|
26
|
+
# @return [Resource] the record Housecall Pro files under this ID.
|
|
27
|
+
def find(id, company_id: nil) = all(company_id: company_id).find(id)
|
|
28
|
+
|
|
29
|
+
# @return [Hash] what a list of these may be narrowed by.
|
|
30
|
+
def filters = self::FILTERS
|
|
31
|
+
|
|
32
|
+
# @return [Array<Symbol>] the conditions Housecall Pro takes more than one of.
|
|
33
|
+
def many = self::MANY
|
|
34
|
+
|
|
35
|
+
# @return [Array<Symbol>] what Housecall Pro will put a list of these in order of.
|
|
36
|
+
def sorts = self::SORTS
|
|
37
|
+
|
|
38
|
+
# @return [Array<Symbol>] what these bring back beside themselves where asked to.
|
|
39
|
+
def expands = self::EXPANDS
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a record Housecall Pro books work for and stamps as the work happens.
|
|
3
|
+
module Scheduled
|
|
4
|
+
# @return [Schedule, nil] when the work is booked for.
|
|
5
|
+
def schedule = record Schedule, 'schedule'
|
|
6
|
+
|
|
7
|
+
# @return [Time, nil] when the pro said they were on their way.
|
|
8
|
+
def on_my_way_at = time 'work_timestamps', 'on_my_way_at'
|
|
9
|
+
|
|
10
|
+
# @return [Time, nil] when the work started.
|
|
11
|
+
def started_at = time 'work_timestamps', 'started_at'
|
|
12
|
+
|
|
13
|
+
# @return [Time, nil] when the work was finished.
|
|
14
|
+
def completed_at = time 'work_timestamps', 'completed_at'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a record Housecall Pro moves through a workflow.
|
|
3
|
+
module Statused
|
|
4
|
+
# Housecall Pro narrows a list by one set of words and answers with another, so a caller
|
|
5
|
+
# who filters by :in_progress reads :in_progress back rather than 'in progress'.
|
|
6
|
+
STATUSES = {
|
|
7
|
+
'needs scheduling' => :unscheduled,
|
|
8
|
+
'scheduled' => :scheduled,
|
|
9
|
+
'in progress' => :in_progress,
|
|
10
|
+
'complete rated' => :completed,
|
|
11
|
+
'complete unrated' => :completed,
|
|
12
|
+
'user canceled' => :canceled,
|
|
13
|
+
'pro canceled' => :canceled,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# @return [Symbol, nil] where Housecall Pro files the record in its own workflow.
|
|
17
|
+
def work_status = STATUSES[@node['work_status']]
|
|
18
|
+
|
|
19
|
+
# A rating is not a status, so it reads beside one rather than inside it.
|
|
20
|
+
# @return [Boolean] whether the customer rated the work.
|
|
21
|
+
def rated? = @node['work_status'] == 'complete rated'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Extends a record Housecall Pro stamps with when it was opened and last changed.
|
|
3
|
+
module Timestamped
|
|
4
|
+
# @return [Time, nil] when the record was opened.
|
|
5
|
+
def created_at = time 'created_at'
|
|
6
|
+
|
|
7
|
+
# @return [Time, nil] when the record last changed.
|
|
8
|
+
def updated_at = time 'updated_at'
|
|
9
|
+
end
|
|
10
|
+
end
|
data/lib/hcp/error.rb
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Raised where Housecall Pro refuses a request for rate.
|
|
3
|
+
class TooManyRequests < Error
|
|
4
|
+
# @param message [String] what Housecall Pro said.
|
|
5
|
+
# @param reset_at [Time, nil] when it will answer again.
|
|
6
|
+
def initialize(message, reset_at)
|
|
7
|
+
super message
|
|
8
|
+
@reset_at = reset_at
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Nothing here sleeps: a caller with a queue can bring the whole job back, which is worth
|
|
12
|
+
# more than a worker asleep holding a connection open.
|
|
13
|
+
# @return [Time, nil] when the limit lifts, where Housecall Pro said.
|
|
14
|
+
attr_reader :reset_at
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/hcp/event.rb
CHANGED
data/lib/hcp/filter.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# One condition a list is narrowed by, as the parameters Housecall Pro takes it as.
|
|
3
|
+
class Filter
|
|
4
|
+
# @param bounds [Array<Symbol>] the parameter for each end of a range, or the single one.
|
|
5
|
+
# @param value [Object] a range, or the one value to match.
|
|
6
|
+
def initialize(bounds:, value:)
|
|
7
|
+
@bounds = bounds
|
|
8
|
+
@value = value
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# @return [Hash] the parameters to send, without the end the caller left open.
|
|
12
|
+
def params
|
|
13
|
+
return { @bounds.first => @value } unless @value.is_a? Range
|
|
14
|
+
|
|
15
|
+
refuse
|
|
16
|
+
low, high = @bounds
|
|
17
|
+
{ low => stamp(@value.begin), high => stamp(@value.end) }.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Housecall Pro reads both ends inclusively, so an excluded end would come back anyway.
|
|
23
|
+
def refuse
|
|
24
|
+
raise Error, "#{@bounds.first} takes one value, not a range" if @bounds.one?
|
|
25
|
+
raise Error, "#{@bounds.last} cannot exclude its end" if @value.exclude_end? && @value.end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stamp(value)
|
|
29
|
+
case value
|
|
30
|
+
when Time then value.utc.iso8601
|
|
31
|
+
when Date then value.iso8601
|
|
32
|
+
else value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/hcp/key.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
class << self
|
|
3
|
+
# The Housecall Pro API key every request is read with, where a thread holds none of its own.
|
|
4
|
+
attr_writer :key
|
|
5
|
+
|
|
6
|
+
# @return [String, nil] the key this thread was handed, the one this module was given, or
|
|
7
|
+
# the one the environment carries.
|
|
8
|
+
def key = ActiveSupport::IsolatedExecutionState[:hcp_key] || @key || ENV['HCP_KEY']
|
|
9
|
+
|
|
10
|
+
# Reads as this key for the block, on this thread alone, so a process serving several
|
|
11
|
+
# accounts can hold a key on every thread without any of them seeing another's.
|
|
12
|
+
# @param key [String] the key to read with.
|
|
13
|
+
# @param company_id [String, nil] the location to read as, where the account has several.
|
|
14
|
+
# @yieldparam access [Access] the account the key opens.
|
|
15
|
+
# @return [Object] what the block answered.
|
|
16
|
+
def with_key(key, company_id: nil)
|
|
17
|
+
previous = ActiveSupport::IsolatedExecutionState[:hcp_key]
|
|
18
|
+
ActiveSupport::IsolatedExecutionState[:hcp_key] = key
|
|
19
|
+
yield Access.new(company_id: company_id)
|
|
20
|
+
ensure
|
|
21
|
+
ActiveSupport::IsolatedExecutionState[:hcp_key] = previous
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/hcp/lead/pipeline.rb
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
module Hcp
|
|
2
|
-
|
|
2
|
+
# Where a Housecall Pro user files a lead while they are still chasing it.
|
|
3
|
+
class Lead::Pipeline
|
|
4
|
+
include Keyed
|
|
5
|
+
|
|
3
6
|
def initialize(id: nil, key:, company_id:)
|
|
4
7
|
@id = id
|
|
5
8
|
@key = key
|
|
6
9
|
@company_id = company_id
|
|
7
10
|
end
|
|
8
11
|
|
|
12
|
+
# Moves the lead to the status going by this name.
|
|
13
|
+
# @param status_name [String] the status as the account names it, such as 'Won'.
|
|
9
14
|
def update(status_name:)
|
|
10
15
|
status = find_status_by name: status_name
|
|
11
|
-
raise Error, "Status #{status_name} not found for lead #{@id}" unless status
|
|
12
|
-
|
|
13
16
|
payload = { resource_type: 'lead', resource_id: @id, status_id: status['id'] }.to_json
|
|
14
17
|
response = Net::HTTP.put uri, payload, headers
|
|
15
18
|
raise Error, response.body unless response.is_a? Net::HTTPSuccess
|