jbr 4.0.0 → 6.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/CHANGELOG.md +106 -0
- data/README.md +178 -7
- data/lib/graphql/client.rb +1 -1
- data/lib/graphql/error.rb +10 -0
- data/lib/jbr/account.rb +4 -0
- data/lib/jbr/booking.rb +56 -0
- data/lib/jbr/collection.rb +1 -8
- data/lib/jbr/collections/jobs.rb +2 -2
- data/lib/jbr/collections/technicians.rb +17 -0
- data/lib/jbr/collections/visits.rb +49 -24
- data/lib/jbr/includable.rb +6 -2
- data/lib/jbr/listable.rb +10 -6
- data/lib/jbr/logger.rb +13 -0
- data/lib/jbr/mock/account.rb +3 -0
- data/lib/jbr/mock/technicians.rb +12 -0
- data/lib/jbr/mock/visit.rb +9 -0
- data/lib/jbr/mock/visits.rb +36 -7
- data/lib/jbr/mock.rb +2 -2
- data/lib/jbr/querying.rb +13 -2
- data/lib/jbr/reading.rb +15 -0
- data/lib/jbr/resources/technician.rb +19 -0
- data/lib/jbr/resources/visit.rb +13 -0
- data/lib/jbr/scheduled.rb +36 -0
- data/lib/jbr/version.rb +1 -1
- data/lib/jbr.rb +11 -2
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cabf4447471fd3556e99cc05251c04a201f6329864d98d0650ba8506ff1dcc0a
|
|
4
|
+
data.tar.gz: 8149a10d060f9c265c82a482a53c3eb5646fcb744e41bd8ee136a4eb6b3b3cc5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 770c8fe0d7556995d4484c51953aeb009f06046910d07a7153f1d40a302be2f65a510becbe5bf523375e927367bfe37acde4bc71382e4fe8807f14e0b9cb9c74
|
|
7
|
+
data.tar.gz: 4e479fa10591e9bee8d2cc1c30a79355329d1233410dde79f48a0d094944366ff61bcc6b4e9d582eed4f1bdab27d8b4a2650a8d3f04ac2f2611ad42eca515b56
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,111 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [6.0.0] - 2026-09-16
|
|
4
|
+
|
|
5
|
+
- [Breaking change] The vocabulary is `company` 3.0: `assigned_to` is `of`, so
|
|
6
|
+
`account.visits.of(technician)` is what `account.visits.assigned_to(technician)` was. Jobber
|
|
7
|
+
still narrows it in the one filter, and it still needs no Users scope. The pin is `~> 3.0`.
|
|
8
|
+
|
|
9
|
+
- [Note] `account.windows` -- the free time of a business -- raises `NotImplementedError` here,
|
|
10
|
+
and will until Jobber exposes the hours a business keeps. It does not today: the schema was
|
|
11
|
+
enumerated looking, and there is no `BusinessHours`, `WorkingHours`, `Shift` or `Weekday` type
|
|
12
|
+
of any kind, while `OnlineBookingConfiguration` is four fields and none of them is an hour.
|
|
13
|
+
|
|
14
|
+
- [Note] Every setting around those hours is there, on `requestSettings`:
|
|
15
|
+
`intervalDurationMinutes` is the grain, `earliestAvailabilityMinutes` the notice,
|
|
16
|
+
`bufferDurationMinutes` the padding either side of a job, `efficientSchedulingType` and
|
|
17
|
+
`maxDriveTimeMinutes` whether travel counts. A caller can work free time out from those and
|
|
18
|
+
the visits. Read them off the form that books, not the one marked default, whose
|
|
19
|
+
`bookingType` may be `NONE`.
|
|
20
|
+
|
|
21
|
+
## [5.0.0] - 2026-09-15
|
|
22
|
+
|
|
23
|
+
- [Feature] A scope the app was never granted no longer raises. Jobber answers `An object of
|
|
24
|
+
type User was hidden due to permissions` and hides the object rather than the query, so
|
|
25
|
+
whatever came back beside it is kept: `account.technicians` answers `[]`, and a week asked
|
|
26
|
+
for with `includes(:technicians)` answers the week with nobody on it. `Jbr.logger` takes the
|
|
27
|
+
line saying which type to tick the scope for -- standard error until an app points it
|
|
28
|
+
somewhere better. Jobber codes this refusal not at all, so the words are the only signal, and
|
|
29
|
+
only those words are carried on from: anything else raises, an empty list being a poor place
|
|
30
|
+
to hide a fault.
|
|
31
|
+
|
|
32
|
+
- [Breaking change] `account.visits.find` is gone. A visit arrives from the walk carrying
|
|
33
|
+
everything it reads, so looking one up again was a request spent to learn nothing -- and it
|
|
34
|
+
answered a job's stop alone, quietly finding nothing for an assessment, an event or a task.
|
|
35
|
+
`Enumerable#find` is what the name means on a list of visits now.
|
|
36
|
+
|
|
37
|
+
- [Feature] `visit.location`, where a stop is, through `includes(:location)`. Jobber hangs the
|
|
38
|
+
property off each kind of scheduled item rather than off what they share, so it is selected
|
|
39
|
+
inside the fragments; a caller reading a schedule no longer reaches through `visit.job` for an
|
|
40
|
+
address, which a stop booked against a lead never had.
|
|
41
|
+
|
|
42
|
+
- [Fix] A window asks for unassigned work and not for unscheduled. `schedulingAspects: [ALL]`
|
|
43
|
+
means both, so a windowed list came back carrying requests nobody had booked -- no start, no
|
|
44
|
+
end, in no window. `includeUnassigned: true` with `includeUnscheduled: false` asks for exactly
|
|
45
|
+
the half that is booked, verified against an account holding plenty of each.
|
|
46
|
+
|
|
47
|
+
- [Breaking change] An event or a task is a visit. They occupy a pro exactly as a job's stop does
|
|
48
|
+
and were being read past unread. Jobber's scheduled item has exactly four kinds -- visit,
|
|
49
|
+
assessment, event, task -- so nothing is filtered out now; a reminder is named by the filter's
|
|
50
|
+
enum but is not a scheduled item and cannot come back. `visit.job` and `visit.lead` are nil on
|
|
51
|
+
an event, and `visit.location` may be: Jobber gives an event a property, and not always.
|
|
52
|
+
|
|
53
|
+
- [Breaking change] The vocabulary is `company` 2.1: a `Company::Selection` takes its rule as a
|
|
54
|
+
block, a visit answers a lead as well as a job, and it says where it is. The pin is `~> 2.1`.
|
|
55
|
+
|
|
56
|
+
- [Feature] The README says which Developer Center object each reader needs ticked. Jobber
|
|
57
|
+
files a visit under Scheduled Items -- one object covering visits, assessments, tasks and
|
|
58
|
+
calendar events -- and there is no Visits scope of its own.
|
|
59
|
+
|
|
60
|
+
- [Feature] `account.visits.create` books a stop to go and look at work nobody has priced. It
|
|
61
|
+
takes the words `leads.create` takes plus `starts_at:`, `ends_at:` and `technicians:`, opens
|
|
62
|
+
the client and the property where Jobber has none, and files all of it in the one
|
|
63
|
+
`requestCreate`: Jobber hangs the assessment off the request it opens with it. What comes
|
|
64
|
+
back is the assessment Jobber stored, naming the request as its lead. Jobber has no source
|
|
65
|
+
for a request, so `source:` is dropped, and `ends_at:` may be nil for a stop booked to a day.
|
|
66
|
+
|
|
67
|
+
A mocked booking refuses a bare `Time` the same way, so a suite cannot pass on one Jobber
|
|
68
|
+
would not take. `starts_at:` must know its zone. Jobber takes a date, a local time and the zone they are in,
|
|
69
|
+
not a moment in UTC, and a bare `Time` names an offset rather than a zone -- so one is
|
|
70
|
+
refused, with `Jbr::Error`, before a client is opened.
|
|
71
|
+
|
|
72
|
+
- [Breaking change] `account.visits` is every stop booked, not only a job's. Jobber calls the
|
|
73
|
+
stop booked to look at work before there is a job an assessment and hangs it off the request,
|
|
74
|
+
so `visit.lead` answers that request and `visit.job` is nil there; `for_jobs` and `for_leads`
|
|
75
|
+
narrow to one kind, by Jobber rather than here. The list is read from `scheduledItems` rather
|
|
76
|
+
than `visits`, which has three consequences worth reading twice:
|
|
77
|
+
|
|
78
|
+
- **A schedule is read by the window.** `scheduledItems` takes a required `occursWithin`, so
|
|
79
|
+
`account.visits` with nothing narrowing it raises `Jbr::Error` where it used to walk every
|
|
80
|
+
visit there was.
|
|
81
|
+
- **An open end reaches a year.** `occursWithin` takes two moments and no nil, so
|
|
82
|
+
`upcoming` and `past` with no duration are bounded at a year rather than left open.
|
|
83
|
+
- **Unassigned work is in.** `scheduledItems` answers assigned work only unless told
|
|
84
|
+
otherwise, so `includeUnassigned: true` is always sent. A list that did not send it would
|
|
85
|
+
quietly drop every stop nobody is on yet.
|
|
86
|
+
|
|
87
|
+
`account.visits.find` still answers a stop of a job alone: Jobber files an assessment under a
|
|
88
|
+
lookup of its own and this gem does not reach for it. Events, tasks and reminders share the
|
|
89
|
+
list and are read past, Jobber's filter taking one kind and not two.
|
|
90
|
+
|
|
91
|
+
- [Feature] `account.technicians` walks the account's users a page at a time, each a
|
|
92
|
+
`Jbr::Technician` reading `id`, `name` and `surname` off the `name` node Jobber answers a
|
|
93
|
+
user with. Reading one needs the Users scope, which an app granted before this
|
|
94
|
+
release does not have: Jobber refuses a query selecting a user outright rather than leaving
|
|
95
|
+
the field empty, so an app that asks for a technician re-authorizes first.
|
|
96
|
+
|
|
97
|
+
- [Feature] `visit.technicians` is whoever a visit is booked for, and `includes(:technicians)`
|
|
98
|
+
is what asks for them -- nothing brings them back unbidden, for the scope above and because
|
|
99
|
+
Jobber prices them on every row that carries them.
|
|
100
|
+
|
|
101
|
+
- [Feature] `account.visits.between(from, to).assigned_to(technician)` is one technician's
|
|
102
|
+
week. `VisitFilterAttributes` takes an `assignedTo`, so the technician joins the window in
|
|
103
|
+
the one filter Jobber is sent: nobody else's visits are answered, paged or paid for, and the
|
|
104
|
+
crew is not read unless `includes(:technicians)` asks, which means `assigned_to` needs no
|
|
105
|
+
Users scope. Both narrowings land in the same filter, so either order asks the same
|
|
106
|
+
thing. `Jbr.mock.technicians` mocks the crew, and a mocked visit takes a `technicians:` of
|
|
107
|
+
its own.
|
|
108
|
+
|
|
3
109
|
## [4.0.0] - 2026-09-09
|
|
4
110
|
|
|
5
111
|
- [Breaking change] `Jbr::Account` is the gateway, and reads in the vocabulary the `company` gem
|
data/README.md
CHANGED
|
@@ -13,10 +13,10 @@ To install on your system, run
|
|
|
13
13
|
|
|
14
14
|
To use inside a bundled Ruby project, add this line to the `Gemfile`:
|
|
15
15
|
|
|
16
|
-
gem 'jbr', '~>
|
|
16
|
+
gem 'jbr', '~> 5.0'
|
|
17
17
|
|
|
18
18
|
Semantic Versioning promises that `~> major.minor` never crosses a breaking change, so the pin
|
|
19
|
-
takes every
|
|
19
|
+
takes every 5.x release and stops short of 6.0.
|
|
20
20
|
|
|
21
21
|
## Available methods
|
|
22
22
|
|
|
@@ -104,6 +104,50 @@ account.query '{ ok }' # => {} once they are refused, raises Jbr::Error where Jo
|
|
|
104
104
|
The app's own client ID and secret are read from `JOBBER_CLIENT_ID` and `JOBBER_CLIENT_SECRET`
|
|
105
105
|
in the environment, and `Jbr::Account.client_secret` answers the one to check a webhook with.
|
|
106
106
|
|
|
107
|
+
### Scopes
|
|
108
|
+
|
|
109
|
+
Jobber has no scope parameter: an app is granted what its Developer Center page ticks, and a
|
|
110
|
+
query selecting anything it was not granted is refused whole rather than answered with the one
|
|
111
|
+
field empty. So a scope left unticked is not a nil somewhere, it is every query that touches it
|
|
112
|
+
failing. What each reader here needs:
|
|
113
|
+
|
|
114
|
+
| What a caller asks for | Object to tick |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `account.jobs`, `job.lines` | Jobs |
|
|
117
|
+
| `job.location`, `location.customer`, `visit.job` | Jobs and Clients |
|
|
118
|
+
| `account.visits`, `of` | Scheduled Items |
|
|
119
|
+
| `account.technicians`, `visit.technicians`, `includes(:technicians)` | Users |
|
|
120
|
+
| `account.quotes` | Quotes |
|
|
121
|
+
| `account.invoices` | Invoices |
|
|
122
|
+
| `account.leads.create` | Requests and Clients, both writing |
|
|
123
|
+
|
|
124
|
+
Jobber files a visit under **Scheduled Items**, which is one object covering visits,
|
|
125
|
+
assessments, tasks and calendar events -- so there is no scope to add for the kinds of booked
|
|
126
|
+
time this gem does not read yet.
|
|
127
|
+
|
|
128
|
+
`of` is the one worth knowing: narrowing to a technician needs no Users, because
|
|
129
|
+
Jobber does the narrowing and no user is ever selected. Reading *who* is on a visit is what
|
|
130
|
+
needs it.
|
|
131
|
+
|
|
132
|
+
The mapping above is read off what each query selects, not published by Jobber, so an app that
|
|
133
|
+
is refused has one more object to tick than this table knows about. The names are the ones the
|
|
134
|
+
Developer Center shows beside the checkboxes.
|
|
135
|
+
|
|
136
|
+
An app that asks for what it was never granted is not left broken. Jobber answers
|
|
137
|
+
`An object of type User was hidden due to permissions` and hides the object, so a reader added
|
|
138
|
+
after an account authorized would otherwise take down every query carrying it:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
Jbr.logger = Rails.logger # standard error until an app names somewhere better
|
|
142
|
+
account.technicians.to_a # => [], with a line in the log naming the type to tick the scope for
|
|
143
|
+
account.visits.upcoming(1.week).includes(:technicians).to_a # => the week, with nobody on it
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
What Jobber hides is the object and not the query, so whatever came back beside it is kept: a
|
|
147
|
+
list that asked for one thing too many still answers everything else it asked for. Jobber codes
|
|
148
|
+
this refusal not at all, so the words are the only signal there is -- and only those words are
|
|
149
|
+
carried on from. Anything else still raises: an empty list is a poor place to hide a fault.
|
|
150
|
+
|
|
107
151
|
### Leads
|
|
108
152
|
|
|
109
153
|
File a request on the account's board, against the client answering to the phone and the
|
|
@@ -200,22 +244,133 @@ invoice.fulfilled_at # => 2026-05-22 14:32:53, when the job was finished, or the
|
|
|
200
244
|
|
|
201
245
|
### Visits
|
|
202
246
|
|
|
203
|
-
|
|
247
|
+
A visit is any booked time: somebody is out somewhere for an hour. A stop of a job is one. So is
|
|
248
|
+
an *assessment*, the stop booked to go and look at work before there is a job, which Jobber hangs
|
|
249
|
+
off the request -- the lead. So is an event or a task, an hour blocked out against nothing at all.
|
|
250
|
+
All of them are scheduled items, all of them mean the pro is not free, and all are read from one
|
|
251
|
+
list. Jobber has exactly four kinds -- a visit, an assessment, an event and a task -- so none is
|
|
252
|
+
read past. A reminder is named by the filter's enum and is not a scheduled item at all, so it
|
|
253
|
+
cannot come back.
|
|
254
|
+
|
|
255
|
+
Where a stop is, is asked for: Jobber hangs the property off each kind rather than off what they
|
|
256
|
+
share, and prices it per row, so `includes(:location)` is what turns it on.
|
|
204
257
|
|
|
205
258
|
```ruby
|
|
206
259
|
account.visits.upcoming(3.months) # => only as far ahead as three months
|
|
207
260
|
account.visits.upcoming.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else
|
|
208
261
|
|
|
209
|
-
visit = account.visits.
|
|
262
|
+
visit = account.visits.upcoming(1.week).first
|
|
210
263
|
visit.id # => 'Z2lkOi8vS'
|
|
211
264
|
visit.description # => 'Furnace tune-up', or nil where nobody titled it
|
|
212
265
|
visit.starts_at # => 2026-08-09 14:00:00
|
|
213
266
|
visit.ends_at # => 2026-08-09 16:00:00
|
|
214
267
|
visit.anytime? # => false
|
|
215
268
|
visit.confirmed? # => true, which Jobber alone asks a client
|
|
216
|
-
visit.
|
|
269
|
+
visit.location # => where the stop is, where `includes(:location)` asked
|
|
270
|
+
visit.job # => the job the stop belongs to, or nil where no job was booked for it
|
|
271
|
+
visit.lead # => the request it was booked against, or nil where no lead was booked for it
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Either kind alone is one question rather than two, asked of Jobber rather than sifted here:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
account.visits.upcoming(1.week).for_jobs # => only the stops of jobs
|
|
278
|
+
account.visits.upcoming(1.week).for_leads # => only the assessments
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**A schedule is read by the window.** Jobber will not list a scheduled item without one, so
|
|
282
|
+
`account.visits` with nothing narrowing it raises rather than walking every visit there ever
|
|
283
|
+
was. `between`, `upcoming` and `past` all supply one. Jobber's window also takes two moments
|
|
284
|
+
and no nil, so `upcoming` and `past` with no duration -- which on a list of jobs means as far
|
|
285
|
+
as there is -- reach a year here, and no further.
|
|
286
|
+
|
|
287
|
+
Book one to go and look at work nobody has priced, against the client answering to the phone
|
|
288
|
+
and the property at the address, opening either where Jobber has none:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
monday = Time.find_zone('America/New_York').local(2026, 9, 21, 13)
|
|
292
|
+
visit = account.visits.create name: 'Jane', surname: 'Doe', phone: '5553335555',
|
|
293
|
+
email: 'jane@example.com', address: { street: '1 Main St', city: 'Newark', zip: '07102' },
|
|
294
|
+
description: 'Look at the roof', notes: 'Ring twice', source: 'Website',
|
|
295
|
+
starts_at: monday, ends_at: monday + 1.hour, technicians: [technician]
|
|
296
|
+
|
|
297
|
+
visit.id # => 'Z2lkOi8vSm9iYmVyL0Fzc2Vzc21lbnQv', the assessment Jobber filed
|
|
298
|
+
visit.lead.id # => 'Z2lkOi8vSm9iYmVyL1JlcXVlc3Qv', the request it hangs off
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
One mutation files the lead, the hour and the crew, and what comes back is what Jobber stored
|
|
302
|
+
rather than what it was asked for. Jobber has no source for a request, so `source:` is dropped.
|
|
303
|
+
`ends_at:` may be nil, for a stop booked to a day rather than an hour.
|
|
304
|
+
|
|
305
|
+
**`starts_at:` has to know its zone.** Jobber books in the words of whoever is going -- a date,
|
|
306
|
+
a local time, and the zone they are in -- rather than the moment in UTC those come to. A bare
|
|
307
|
+
`Time` names an offset, and an offset is not a zone: the same one stands for several, and none
|
|
308
|
+
of them says when the clocks go back. So hand over a `Time.zone` moment; a `Time` is refused
|
|
309
|
+
before anything is opened.
|
|
310
|
+
|
|
311
|
+
### The schedule
|
|
312
|
+
|
|
313
|
+
Jobber calls a technician a user, and reading one needs the Users scope. Without it
|
|
314
|
+
Jobber refuses the whole query rather than the one field, so nothing asks who is on a visit
|
|
315
|
+
unless a caller does. Narrowing *to* a technician needs no such scope -- only reading one back:
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
technician = account.technicians.first
|
|
319
|
+
technician.id, technician.name, technician.surname # => 'Z2lkOi8vVXNlc', 'Grace', 'Hopper'
|
|
320
|
+
|
|
321
|
+
account.visits.includes(:technicians).upcoming.first.technicians # => [#<Jbr::Technician>, ...]
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
One technician's week is the visits in it narrowed to them:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
monday = Date.today.beginning_of_week.in_time_zone
|
|
328
|
+
account.visits.between(monday, monday + 1.week).of(technician).each do |visit|
|
|
329
|
+
visit.starts_at, visit.ends_at, visit.job.id
|
|
330
|
+
end
|
|
217
331
|
```
|
|
218
332
|
|
|
333
|
+
Jobber narrows a list of visits by who is on it, so `of` puts the technician into the
|
|
334
|
+
same filter as the window: nobody else's visits are answered, paged or paid for, and the crew
|
|
335
|
+
is not read at all unless `includes(:technicians)` asks. The two narrowings land in the one
|
|
336
|
+
filter, so a caller may ask for the week and the technician in either order.
|
|
337
|
+
|
|
338
|
+
`of` therefore needs no Users scope. Reading *who* is on a visit does.
|
|
339
|
+
|
|
340
|
+
## What Jobber cannot say
|
|
341
|
+
|
|
342
|
+
`account.windows` -- the free time of a business, which the vocabulary names -- raises
|
|
343
|
+
`NotImplementedError` here, and will go on doing so until Jobber exposes the hours a business
|
|
344
|
+
keeps.
|
|
345
|
+
|
|
346
|
+
It does not today. The whole schema was enumerated looking for it: there is no `BusinessHours`,
|
|
347
|
+
`WorkingHours`, `OperatingHours`, `Shift`, `Weekday` or `DayOfWeek` type of any kind, and
|
|
348
|
+
`OnlineBookingConfiguration` is four fields -- whether bookings are on, the URL, the embed
|
|
349
|
+
script, the id. Jobber's own booking page must know the hours; the API does not say them.
|
|
350
|
+
|
|
351
|
+
What it does say is every setting around them, on `requestSettings`:
|
|
352
|
+
`intervalDurationMinutes` is the grain an offer is cut to, `earliestAvailabilityMinutes` the
|
|
353
|
+
notice the business needs, `bufferDurationMinutes` the padding left either side of a job, and
|
|
354
|
+
`efficientSchedulingType` with `maxDriveTimeMinutes` whether travel is counted as well. Read
|
|
355
|
+
those beside the visits and a caller can work free time out for itself -- which is what the
|
|
356
|
+
missing reader would have done. Take the settings from the form that books, not the one marked
|
|
357
|
+
default: the default form may be a request form, whose `bookingType` is `NONE`.
|
|
358
|
+
|
|
359
|
+
Raising is deliberate rather than answering with none. An empty week and a fully booked one are
|
|
360
|
+
the same shape, so a caller reading none as none would quietly stop offering the business
|
|
361
|
+
altogether, with nothing in a log and no page looking wrong.
|
|
362
|
+
|
|
363
|
+
A visit is still not all Jobber schedules. A task, an event and the two kinds of reminder sit
|
|
364
|
+
on the same calendar and are read past unread, because Jobber's filter takes one kind and not
|
|
365
|
+
two, so both the kinds that are visits are asked for and the rest let go as they arrive.
|
|
366
|
+
|
|
367
|
+
One thing worth knowing about that list: `scheduledItems` answers assigned work only unless it
|
|
368
|
+
is told otherwise, so every window carries `includeUnassigned: true` -- without it a week is
|
|
369
|
+
quietly missing every stop nobody has been put on yet. It carries `includeUnscheduled: false`
|
|
370
|
+
in the same breath, because asking for unassigned work through `schedulingAspects: [ALL]` is
|
|
371
|
+
answered with unscheduled work as well: requests filed and never booked, with no hour to
|
|
372
|
+
occupy and no place in any window.
|
|
373
|
+
|
|
219
374
|
### Locations and customers
|
|
220
375
|
|
|
221
376
|
Jobber prices a query by what it brings back, so nothing nested comes back unless it is
|
|
@@ -260,7 +415,10 @@ decision belongs to whoever called: from a background job, letting it fail so th
|
|
|
260
415
|
it back is better than a worker asleep holding a transaction open.
|
|
261
416
|
|
|
262
417
|
Every connection the gem asks for is bounded, to keep a query on the affordable side of that:
|
|
263
|
-
twenty lines to a job, and twenty jobs or
|
|
418
|
+
twenty lines to a job, ten technicians to a visit, and twenty jobs, visits or technicians to a
|
|
419
|
+
page, whichever kinds the page holds. Who is on a visit is priced on top of every row that
|
|
420
|
+
carries it, so `includes(:technicians)` costs more per page -- which is why narrowing to one
|
|
421
|
+
technician does not use it.
|
|
264
422
|
|
|
265
423
|
`ids` is the cheap way to walk an account. It asks for the ID and nothing else, which prices
|
|
266
424
|
a row at a fraction of a record and buys a hundred of them to a page. Reach for it where each
|
|
@@ -360,9 +518,22 @@ Jbr.mock.visits = [ { id: 'visit-01', description: 'Furnace tune-up',
|
|
|
360
518
|
location: { id: 'property-01', street: '1 Main St',
|
|
361
519
|
customer: { id: 'client-01', name: 'Jane' } },
|
|
362
520
|
starts_at: Date.tomorrow.noon, ends_at: Date.tomorrow.end_of_day,
|
|
363
|
-
anytime: false, confirmed: true
|
|
521
|
+
anytime: false, confirmed: true,
|
|
522
|
+
technicians: [ { id: 'user-01', name: 'Grace', surname: 'Hopper' } ] } ]
|
|
364
523
|
```
|
|
365
524
|
|
|
525
|
+
### Technicians
|
|
526
|
+
|
|
527
|
+
Mock the crew the account has:
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
Jbr.mock.technicians = [ { id: 'user-01', name: 'Grace', surname: 'Hopper' } ]
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
A mocked `visits.create` reaches nobody and answers the hour and the crew it was handed, with
|
|
534
|
+
`Jbr.mock.lead` for the lead it hangs off. It refuses a moment naming no zone exactly as Jobber
|
|
535
|
+
does, so a suite cannot pass on a booking that could not be made.
|
|
536
|
+
|
|
366
537
|
### Invoices
|
|
367
538
|
|
|
368
539
|
Mock successfully fetching an invoice:
|
data/lib/graphql/client.rb
CHANGED
|
@@ -34,7 +34,7 @@ module GraphQL
|
|
|
34
34
|
coded = body['errors'].any? { |error| error.to_h.dig('extensions', 'code') == 'THROTTLED' }
|
|
35
35
|
priced = available && cost['requestedQueryCost'].to_f > available.to_f
|
|
36
36
|
|
|
37
|
-
(coded || priced ? Throttled : Error).new refusal(body)
|
|
37
|
+
(coded || priced ? Throttled : Error).new refusal(body), data: body['data']
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def refusal(body)
|
data/lib/graphql/error.rb
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
module GraphQL
|
|
2
2
|
# An error raised when a GraphQL request fails.
|
|
3
3
|
class Error < StandardError
|
|
4
|
+
# @param message [String] what the API said went wrong.
|
|
5
|
+
# @param data [Hash, nil] what it answered alongside, where it answered anything: an API that
|
|
6
|
+
# refuses one field of a query and fulfils the rest sends both at once.
|
|
7
|
+
def initialize(message = nil, data: nil)
|
|
8
|
+
super message
|
|
9
|
+
@data = data
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Hash, nil] what came back beside the refusal, nil where nothing did.
|
|
13
|
+
attr_reader :data
|
|
4
14
|
end
|
|
5
15
|
end
|
data/lib/jbr/account.rb
CHANGED
|
@@ -40,6 +40,10 @@ module Jbr
|
|
|
40
40
|
# @return [Visits] visits of the business.
|
|
41
41
|
def visits = Visits.new account: self
|
|
42
42
|
|
|
43
|
+
# @return [Technicians] users of the business.
|
|
44
|
+
# @note Needs the Users scope, without which Jobber refuses the query rather than the field.
|
|
45
|
+
def technicians = Technicians.new account: self
|
|
46
|
+
|
|
43
47
|
# @return [Quotes] quotes of the business, which Jobber alone lists.
|
|
44
48
|
def quotes = Quotes.new account: self
|
|
45
49
|
|
data/lib/jbr/booking.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# What a list of visits writes. Jobber files the stop booked to go and look at work as an
|
|
3
|
+
# assessment on the request it is opened with, so one mutation files the lead, the hour and
|
|
4
|
+
# the crew, and answers the stop as Jobber stored it rather than as it was asked for.
|
|
5
|
+
module Booking
|
|
6
|
+
# The mutation that opens a request with an assessment booked on it.
|
|
7
|
+
CREATE = <<~GRAPHQL
|
|
8
|
+
mutation($input: RequestCreateInput!) {
|
|
9
|
+
requestCreate(input: $input) {
|
|
10
|
+
request { id client { id } assessment { id title startAt endAt allDay } }
|
|
11
|
+
userErrors { message }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
GRAPHQL
|
|
15
|
+
|
|
16
|
+
# Books a stop against the client answering to the phone and the property at the address,
|
|
17
|
+
# opening either where Jobber has none. Jobber has no source for a request, so that one is
|
|
18
|
+
# dropped, and it takes the hour in the technician's own words, so `starts_at:` must know
|
|
19
|
+
# the zone it is in: a bare Time names no zone and is refused.
|
|
20
|
+
# @return [Visit] stop as Jobber booked it, naming the lead it was booked against.
|
|
21
|
+
def create(name:, surname:, phone:, email:, address:, description:, notes:, source:,
|
|
22
|
+
starts_at:, ends_at:, technicians:)
|
|
23
|
+
booking = schedule starts_at, ends_at, technicians
|
|
24
|
+
customer = Customers.new(account: @account).find_or_create_by phone: phone, name: name,
|
|
25
|
+
surname: surname, email: email, address: address
|
|
26
|
+
property = Locations.new(account: @account).find_or_create_for customer, address
|
|
27
|
+
booked @account.query(CREATE, variables: { input: {
|
|
28
|
+
clientId: customer.id, propertyId: property, title: description,
|
|
29
|
+
assessment: { instructions: notes, schedule: booking },
|
|
30
|
+
} })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def booked(output)
|
|
36
|
+
request = output.dig 'requestCreate', 'request'
|
|
37
|
+
lead = { 'id' => request['id'], 'client' => request['client'] }
|
|
38
|
+
Visit.new node: request.fetch('assessment').merge('request' => lead)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def schedule(starts_at, ends_at, technicians)
|
|
42
|
+
{ startAt: moment(starts_at), endAt: (moment(ends_at) if ends_at),
|
|
43
|
+
teamMemberIdsToAssign: technicians.map(&:id) }.compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def moment(at)
|
|
47
|
+
{ date: at.strftime('%Y-%m-%d'), time: at.strftime('%H:%M:%S'), timezone: zone_of(at) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def zone_of(at)
|
|
51
|
+
return at.time_zone.tzinfo.name if at.respond_to? :time_zone
|
|
52
|
+
|
|
53
|
+
raise Error, "Jobber books #{at} in a named zone: hand over a Time.zone moment, not a Time"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/jbr/collection.rb
CHANGED
|
@@ -2,13 +2,6 @@ module Jbr
|
|
|
2
2
|
# A list of records read through the credentials, with what it was asked to bring back beside
|
|
3
3
|
# each record and what it was narrowed to.
|
|
4
4
|
class Collection < Company::Collection
|
|
5
|
-
|
|
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
|
|
5
|
+
include Reading
|
|
13
6
|
end
|
|
14
7
|
end
|
data/lib/jbr/collections/jobs.rb
CHANGED
|
@@ -19,12 +19,12 @@ module Jbr
|
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
-
def page = paged
|
|
22
|
+
def page = paged row(FIELDS), PAGE
|
|
23
23
|
|
|
24
24
|
def one
|
|
25
25
|
<<~GRAPHQL
|
|
26
26
|
query($id: EncodedId!) {
|
|
27
|
-
job(id: $id) { #{FIELDS}
|
|
27
|
+
job(id: $id) { #{row FIELDS} }
|
|
28
28
|
}
|
|
29
29
|
GRAPHQL
|
|
30
30
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The users on a Jobber account, walked a page at a time. Reading one at all needs the Users
|
|
3
|
+
# scope; narrowing a list to one does not.
|
|
4
|
+
class Technicians < Collection
|
|
5
|
+
include Listable
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def page = paged Technician::FIELDS, PAGE
|
|
10
|
+
|
|
11
|
+
def field = 'users'
|
|
12
|
+
|
|
13
|
+
def filtered = 'UsersFilterAttributes'
|
|
14
|
+
|
|
15
|
+
def item(node) = Technician.new node: node
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,36 +1,61 @@
|
|
|
1
1
|
module Jbr
|
|
2
|
-
# The visits on a Jobber account
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# @
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
2
|
+
# The visits on a Jobber account: the stops of its jobs, and the assessments booked to go and
|
|
3
|
+
# look at work before there is a job. Jobber files both as scheduled items and refuses to list
|
|
4
|
+
# one without a window, so a list nothing narrowed has none to give and says so.
|
|
5
|
+
class Visits < Company::Visits
|
|
6
|
+
include Reading, Scheduled, Includable, Listable, Booking
|
|
7
|
+
|
|
8
|
+
# How far an open end reaches. Jobber's window takes two moments and no nil, so a caller
|
|
9
|
+
# who named no end gets a year of one, which is a schedule rather than an archive.
|
|
10
|
+
HORIZON = 1.year
|
|
11
|
+
|
|
12
|
+
# @param from [Time, nil] the moment the window opens, or nothing for as far back as there is.
|
|
13
|
+
# @param to [Time, nil] the moment the window closes, or nothing for as far ahead as there is.
|
|
14
|
+
# @return [Visits] the same list, narrowed to what the window holds.
|
|
15
|
+
def between(from, to)
|
|
16
|
+
now = Time.now
|
|
17
|
+
narrowed occursWithin: { startAt: (from || now - HORIZON).iso8601,
|
|
18
|
+
endAt: (to || now + HORIZON).iso8601 },
|
|
19
|
+
includeUnassigned: true, includeUnscheduled: false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Jobber narrows a schedule by who is on it, so the technician joins the window in the one
|
|
23
|
+
# filter and nobody else's work is answered, paged or paid for.
|
|
24
|
+
# @param technician [Company::Technician] whoever the work is booked for.
|
|
25
|
+
# @return [Visits] the same list, narrowed to what they are booked for.
|
|
26
|
+
# @note Needs no Users scope: Jobber narrows, and no user is selected to do it.
|
|
27
|
+
def of(technician) = narrowed(assignedTo: [ technician.id ])
|
|
28
|
+
|
|
29
|
+
# @return [Visits] the same list, narrowed to the stops of jobs, by Jobber rather than here.
|
|
30
|
+
def for_jobs = narrowed(scheduleItemType: 'VISIT')
|
|
31
|
+
|
|
32
|
+
# @return [Visits] the same list, narrowed to the assessments, by Jobber rather than here.
|
|
33
|
+
def for_leads = narrowed(scheduleItemType: 'ASSESSMENT')
|
|
34
|
+
|
|
35
|
+
# @yield [Visit] each visit in the window, oldest first.
|
|
36
|
+
def each(&)
|
|
37
|
+
windowed
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Array<String>] every ID in the window, every page of them read.
|
|
42
|
+
def ids
|
|
43
|
+
windowed
|
|
44
|
+
super
|
|
16
45
|
end
|
|
17
46
|
|
|
18
47
|
private
|
|
19
48
|
|
|
20
|
-
def
|
|
49
|
+
def windowed
|
|
50
|
+
return if @filter&.key? :occursWithin
|
|
21
51
|
|
|
22
|
-
|
|
23
|
-
<<~GRAPHQL
|
|
24
|
-
query($id: EncodedId!) {
|
|
25
|
-
visit(id: $id) { #{FIELDS} }
|
|
26
|
-
}
|
|
27
|
-
GRAPHQL
|
|
52
|
+
raise Error, 'A Jobber schedule is read by the window: ask between, upcoming or past'
|
|
28
53
|
end
|
|
29
54
|
|
|
30
|
-
def
|
|
55
|
+
def page = paged row(fields), PAGE
|
|
31
56
|
|
|
32
|
-
def
|
|
57
|
+
def field = 'scheduledItems'
|
|
33
58
|
|
|
34
|
-
def
|
|
59
|
+
def filtered = 'ScheduledItemsFilterAttributes!'
|
|
35
60
|
end
|
|
36
61
|
end
|
data/lib/jbr/includable.rb
CHANGED
|
@@ -2,9 +2,10 @@ 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>] :lines, :location, or location: :customer
|
|
6
|
-
# place it is.
|
|
5
|
+
# @param names [Array<Symbol, Hash>] :lines, :technicians, :location, or location: :customer
|
|
6
|
+
# for whose place it is.
|
|
7
7
|
# @return [Collection] the same list, asking Jobber for those too.
|
|
8
|
+
# @note :technicians needs the Users scope, which refuses the whole query where it is unticked.
|
|
8
9
|
def includes(*names)
|
|
9
10
|
named = names.each_with_object({}) do |name, all|
|
|
10
11
|
name.is_a?(Hash) ? all.merge!(name) : all[name] = nil
|
|
@@ -14,11 +15,14 @@ module Jbr
|
|
|
14
15
|
|
|
15
16
|
private
|
|
16
17
|
|
|
18
|
+
def row(fields) = [ fields, selections ].compact_blank.join ' '
|
|
19
|
+
|
|
17
20
|
def selections = @includes.map { |name, nested| selection_of name, nested }.join ' '
|
|
18
21
|
|
|
19
22
|
def selection_of(name, nested)
|
|
20
23
|
case name
|
|
21
24
|
when :lines then Line::SELECTION
|
|
25
|
+
when :technicians then Technician::SELECTION
|
|
22
26
|
when :location then Location.selection customer: nested == :customer
|
|
23
27
|
end
|
|
24
28
|
end
|
data/lib/jbr/listable.rb
CHANGED
|
@@ -22,10 +22,7 @@ module Jbr
|
|
|
22
22
|
# @param from [Time, nil] the moment the window opens, or nothing for as far back as it goes.
|
|
23
23
|
# @param to [Time, nil] the moment the window closes, or nothing for as far ahead as it goes.
|
|
24
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 }
|
|
28
|
-
end
|
|
25
|
+
def between(from, to) = narrowed(startAt: { after: from&.iso8601, before: to&.iso8601 }.compact)
|
|
29
26
|
|
|
30
27
|
# The ID Jobber files each record under, and nothing else about it: the cheapest question
|
|
31
28
|
# an account can be walked with, and the one to ask where every record is then read on its
|
|
@@ -35,10 +32,17 @@ module Jbr
|
|
|
35
32
|
|
|
36
33
|
private
|
|
37
34
|
|
|
35
|
+
# Every narrowing lands in the one filter Jobber takes, so asking for a week and asking for
|
|
36
|
+
# a technician compose whichever way round a caller writes them.
|
|
37
|
+
def narrowed(**more)
|
|
38
|
+
self.class.new account: @account, includes: @includes, filter: @filter.to_h.merge(more)
|
|
39
|
+
end
|
|
40
|
+
|
|
38
41
|
def scheduled?(at)
|
|
39
|
-
|
|
42
|
+
bounds = @filter&.dig :startAt
|
|
43
|
+
return true unless bounds
|
|
40
44
|
|
|
41
|
-
after, before =
|
|
45
|
+
after, before = bounds.values_at :after, :before
|
|
42
46
|
return before.nil? unless at
|
|
43
47
|
|
|
44
48
|
(after.nil? || at >= Time.iso8601(after)) && (before.nil? || at <= Time.iso8601(before))
|
data/lib/jbr/logger.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require 'logger'
|
|
2
|
+
|
|
3
|
+
module Jbr
|
|
4
|
+
class << self
|
|
5
|
+
# Where this gem says what it could not do, for the things it carries on from rather than
|
|
6
|
+
# raises. An app with a log of its own points this at it.
|
|
7
|
+
# @return [Logger] somewhere to write, standard error until an app names somewhere better.
|
|
8
|
+
def logger = @logger ||= Logger.new($stderr)
|
|
9
|
+
|
|
10
|
+
# @return [Logger] where to write.
|
|
11
|
+
attr_writer :logger
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/jbr/mock/account.rb
CHANGED
|
@@ -11,6 +11,9 @@ module Jbr
|
|
|
11
11
|
# @return [Mock::Visits] visits the app listed.
|
|
12
12
|
def visits = Mock::Visits.new account: self
|
|
13
13
|
|
|
14
|
+
# @return [Mock::Technicians] technicians the app listed.
|
|
15
|
+
def technicians = Mock::Technicians.new account: self
|
|
16
|
+
|
|
14
17
|
# @return [Mock::Quotes] the one quote the app named.
|
|
15
18
|
def quotes = Mock::Quotes.new
|
|
16
19
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# The technicians an app under test asked {Jbr.mock} to answer with.
|
|
3
|
+
class Mock::Technicians < Technicians
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def walk(_statement)
|
|
7
|
+
Enumerator.new do |yielder|
|
|
8
|
+
Jbr.mock.technicians.to_a.each { |node| yielder << Company::Technician.new(node: node) }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/jbr/mock/visit.rb
CHANGED
|
@@ -3,5 +3,14 @@ module Jbr
|
|
|
3
3
|
class Mock::Visit < Visit
|
|
4
4
|
# The mock spells every key as the reader is named.
|
|
5
5
|
def self.keys = {}
|
|
6
|
+
|
|
7
|
+
# @return [Array<Company::Technician>] whoever the app said the visit is booked for.
|
|
8
|
+
def technicians = records Company::Technician, :technicians
|
|
9
|
+
|
|
10
|
+
# @return [Company::Lead, nil] lead the app said the visit was booked against.
|
|
11
|
+
def lead = record Company::Lead, :lead
|
|
12
|
+
|
|
13
|
+
# @return [Company::Location, nil] where the app said the visit is.
|
|
14
|
+
def location = record Company::Location, :location
|
|
6
15
|
end
|
|
7
16
|
end
|
data/lib/jbr/mock/visits.rb
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
module Jbr
|
|
2
2
|
# The visits an app under test asked {Jbr.mock} to answer with. Only the walk is mocked:
|
|
3
|
-
# narrowing a list, and reading it as records or as IDs, is the same code a real one runs
|
|
3
|
+
# narrowing a list, and reading it as records or as IDs, is the same code a real one runs,
|
|
4
|
+
# so a list nothing narrowed refuses here exactly as Jobber refuses it.
|
|
4
5
|
class Mock::Visits < Visits
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
# Jobber will not book a moment that names no zone, so neither will this: an app whose
|
|
7
|
+
# tests pass a bare Time would otherwise only learn of it in production.
|
|
8
|
+
# @return [Mock::Visit] stop the app listed as its lead, booked for the hour asked for.
|
|
9
|
+
def create(starts_at:, ends_at:, technicians:, **)
|
|
10
|
+
zone_of starts_at
|
|
11
|
+
Mock::Visit.new node: { id: 'visit-01', starts_at: starts_at, ends_at: ends_at,
|
|
12
|
+
technicians: technicians.map { |each| { id: each.id } },
|
|
13
|
+
lead: Jbr.mock.lead.to_h, }
|
|
10
14
|
end
|
|
11
15
|
|
|
12
16
|
private
|
|
@@ -17,6 +21,31 @@ module Jbr
|
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
def selected
|
|
24
|
+
def selected
|
|
25
|
+
Jbr.mock.visits.select do |visit|
|
|
26
|
+
scheduled?(visit[:starts_at]) && assigned?(visit) && kind?(visit)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def scheduled?(at)
|
|
31
|
+
window = @filter&.dig :occursWithin
|
|
32
|
+
return true unless window && at
|
|
33
|
+
|
|
34
|
+
at >= Time.iso8601(window[:startAt]) && at <= Time.iso8601(window[:endAt])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def assigned?(visit)
|
|
38
|
+
wanted = @filter&.dig :assignedTo
|
|
39
|
+
return true unless wanted
|
|
40
|
+
|
|
41
|
+
Array(visit[:technicians]).any? { |technician| wanted.include? technician[:id] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def kind?(visit)
|
|
45
|
+
kind = @filter&.dig :scheduleItemType
|
|
46
|
+
return true unless kind
|
|
47
|
+
|
|
48
|
+
kind == 'VISIT' ? !visit[:job].nil? : !visit[:lead].nil?
|
|
49
|
+
end
|
|
21
50
|
end
|
|
22
51
|
end
|
data/lib/jbr/mock.rb
CHANGED
|
@@ -2,8 +2,8 @@ module Jbr
|
|
|
2
2
|
# What an app under test wants Jobber to answer, by the vocabulary's names.
|
|
3
3
|
class Mock
|
|
4
4
|
# The canned answers, each read by the matching mock collection.
|
|
5
|
-
attr_accessor :business, :quote, :job, :invoice, :lead, :jobs, :visits, :
|
|
6
|
-
:oauth_error
|
|
5
|
+
attr_accessor :business, :quote, :job, :invoice, :lead, :jobs, :visits, :technicians,
|
|
6
|
+
:oauth_url, :oauth_error
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
class << self
|
data/lib/jbr/querying.rb
CHANGED
|
@@ -8,6 +8,12 @@ module Jbr
|
|
|
8
8
|
# The version of the schema every statement is written against.
|
|
9
9
|
HEADERS = { 'X-JOBBER-GRAPHQL-VERSION' => '2026-04-22' }
|
|
10
10
|
|
|
11
|
+
# How Jobber says the grant does not cover what was asked for: `An object of type User was
|
|
12
|
+
# hidden due to permissions`. It codes this one not at all, so the words are the only signal
|
|
13
|
+
# there is, and it names the type in them. An app granted its scopes before a reader existed
|
|
14
|
+
# would break on every query carrying that reader; this way it carries on a field short.
|
|
15
|
+
HIDDEN = /hidden due to permissions/
|
|
16
|
+
|
|
11
17
|
# The mutation that revokes the app on the account.
|
|
12
18
|
DISCONNECT = <<~GRAPHQL
|
|
13
19
|
mutation Disconnect {
|
|
@@ -22,7 +28,8 @@ module Jbr
|
|
|
22
28
|
# asking from a background job has a queue that will bring the whole job back later.
|
|
23
29
|
# @param statement [String] query or mutation to run.
|
|
24
30
|
# @param variables [Hash] what the statement takes.
|
|
25
|
-
# @return [Hash] data Jobber answered
|
|
31
|
+
# @return [Hash] data Jobber answered: empty where the credentials are dead, and short of
|
|
32
|
+
# whatever the grant does not cover, which Jobber hides rather than answers.
|
|
26
33
|
# @raise [Throttled] where Jobber refused the statement for what it costs.
|
|
27
34
|
# @raise [Error] where Jobber refused the statement, or took a mutation and would not act.
|
|
28
35
|
def query(statement, variables: {})
|
|
@@ -36,7 +43,11 @@ module Jbr
|
|
|
36
43
|
rescue GraphQL::Throttled => error
|
|
37
44
|
raise Throttled, error.message
|
|
38
45
|
rescue GraphQL::Error => error
|
|
39
|
-
raise Error, error.message
|
|
46
|
+
raise Error, error.message unless error.message.match? HIDDEN
|
|
47
|
+
|
|
48
|
+
Jbr.logger.warn 'Jobber hid what this app is not granted. Tick the scope for the type it ' \
|
|
49
|
+
"names and have the account authorize again. #{error.message}"
|
|
50
|
+
error.data.to_h
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
# Revoke the credentials on the account. Dead ones have nothing left to revoke.
|
data/lib/jbr/reading.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# What a list read through the credentials holds: what to reach Jobber with, what to bring
|
|
3
|
+
# back beside each record, and what the list was narrowed to. A list of visits answers the
|
|
4
|
+
# vocabulary's own {Company::Visits} rather than {Collection}, so the three live here.
|
|
5
|
+
module Reading
|
|
6
|
+
# @param account [Account] credentials to reach Jobber with.
|
|
7
|
+
# @param includes [Hash] what to bring back beside each record, by name.
|
|
8
|
+
# @param filter [Hash, nil] what the list was narrowed to, in the shape Jobber filters by.
|
|
9
|
+
def initialize(account:, includes: {}, filter: nil)
|
|
10
|
+
@account = account
|
|
11
|
+
@includes = includes
|
|
12
|
+
@filter = filter
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# A person the business sends out, whom Jobber calls a user and whose name is a node of its
|
|
3
|
+
# own rather than two fields, so the vocabulary's keys would not fetch it.
|
|
4
|
+
class Technician < Company::Technician
|
|
5
|
+
# What Jobber answers a user with.
|
|
6
|
+
FIELDS = 'id name { first last }'
|
|
7
|
+
|
|
8
|
+
# What to ask for wherever a scheduled item names who it is booked for: ten, which is more
|
|
9
|
+
# than a stop is ever booked for. An app never granted the Users scope has the whole query
|
|
10
|
+
# refused rather than this field left empty, so nothing asks for it unbidden.
|
|
11
|
+
SELECTION = "assignedUsers(first: 10) { nodes { #{FIELDS} } }"
|
|
12
|
+
|
|
13
|
+
# @return [String, nil] what they go by: a given name.
|
|
14
|
+
def name = @node.dig :name, :first
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] their surname.
|
|
17
|
+
def surname = @node.dig :name, :last
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/jbr/resources/visit.rb
CHANGED
|
@@ -13,5 +13,18 @@ module Jbr
|
|
|
13
13
|
|
|
14
14
|
# @return [Boolean, nil] whether the client confirmed the visit.
|
|
15
15
|
def confirmed? = attribute :confirmed
|
|
16
|
+
|
|
17
|
+
# Jobber calls the lead a request, and hangs the assessment booked to look at the work off
|
|
18
|
+
# it; a stop of a job names no request and a stop of a lead names no job.
|
|
19
|
+
# @return [Lead, nil] lead the stop belongs to, nil where the stop is a job's.
|
|
20
|
+
def lead = record Lead, :request
|
|
21
|
+
|
|
22
|
+
# Jobber hangs the property off each kind of scheduled item rather than off what they
|
|
23
|
+
# share, so it is asked for inside each and read back under Jobber's own name for it.
|
|
24
|
+
# @return [Location, nil] where the stop happens, where the query asked and Jobber has one.
|
|
25
|
+
def location = record Location, :property
|
|
26
|
+
|
|
27
|
+
# @return [Array<Technician>] whoever the stop is booked for, where the query asked.
|
|
28
|
+
def technicians = @node.dig(:assignedUsers, :nodes).to_a.map { Technician.new node: it }
|
|
16
29
|
end
|
|
17
30
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Jbr
|
|
2
|
+
# What Jobber files as a scheduled item, and how a list of them is asked for. Which kinds count
|
|
3
|
+
# as booked time lives here, and so does the awkward part: a scheduled item names no property
|
|
4
|
+
# and no job, so where a stop is and what it was booked for hang off each kind rather than off
|
|
5
|
+
# what they share, and are selected inside the fragments.
|
|
6
|
+
module Scheduled
|
|
7
|
+
# What every kind answers with. Jobber has four of them -- a visit, an assessment, an event
|
|
8
|
+
# and a task -- and every one is an hour somebody is out, so no kind is read past. A reminder
|
|
9
|
+
# is named by the filter's enum but implements nothing here, and cannot come back.
|
|
10
|
+
SHARED = 'id title startAt endAt allDay'
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def fields
|
|
15
|
+
[ SHARED, kind('Visit', 'clientConfirmed job { id }'),
|
|
16
|
+
kind('Assessment', 'clientConfirmed request { id }') ].join ' '
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def kind(name, own) = "... on #{name} { #{[ own, located ].compact_blank.join ' '} }"
|
|
20
|
+
|
|
21
|
+
# Included before Includable so this is found first: a location asked for is selected inside
|
|
22
|
+
# each kind's fragment by `kind`, and must not also be appended to what they share, where
|
|
23
|
+
# Jobber has no property to answer with.
|
|
24
|
+
def selection_of(name, nested)
|
|
25
|
+
return if name == :location
|
|
26
|
+
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def located
|
|
31
|
+
Location.selection customer: @includes[:location] == :customer if @includes.key? :location
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def item(node) = Visit.new node: node
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/jbr/version.rb
CHANGED
data/lib/jbr.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
require 'json'
|
|
2
2
|
require 'net/http'
|
|
3
3
|
|
|
4
|
-
# The vocabulary every record here answers in, and the Active Support it brings along.
|
|
5
|
-
# more
|
|
4
|
+
# The vocabulary every record here answers in, and the Active Support it brings along. Two
|
|
5
|
+
# more files of it: an address is sent without the fields a caller left blank, and a window
|
|
6
|
+
# with no end reaches a year out.
|
|
6
7
|
require 'company'
|
|
7
8
|
require 'active_support/core_ext/enumerable'
|
|
9
|
+
require 'active_support/core_ext/integer/time'
|
|
8
10
|
|
|
9
11
|
require 'graphql/error'
|
|
10
12
|
require 'graphql/unauthorized'
|
|
@@ -12,6 +14,7 @@ require 'graphql/throttled'
|
|
|
12
14
|
require 'graphql/client'
|
|
13
15
|
|
|
14
16
|
require 'jbr/version'
|
|
17
|
+
require 'jbr/logger'
|
|
15
18
|
require 'jbr/error'
|
|
16
19
|
require 'jbr/errors/refused'
|
|
17
20
|
require 'jbr/errors/throttled'
|
|
@@ -28,17 +31,22 @@ require 'jbr/resources/customer'
|
|
|
28
31
|
require 'jbr/resources/location'
|
|
29
32
|
require 'jbr/resources/line'
|
|
30
33
|
require 'jbr/resources/job'
|
|
34
|
+
require 'jbr/resources/technician'
|
|
31
35
|
require 'jbr/resources/visit'
|
|
32
36
|
require 'jbr/resources/quote'
|
|
33
37
|
require 'jbr/resources/invoice'
|
|
34
38
|
require 'jbr/resources/lead'
|
|
35
39
|
|
|
36
40
|
require 'jbr/reader'
|
|
41
|
+
require 'jbr/reading'
|
|
37
42
|
require 'jbr/collection'
|
|
38
43
|
require 'jbr/includable'
|
|
39
44
|
require 'jbr/listable'
|
|
45
|
+
require 'jbr/scheduled'
|
|
46
|
+
require 'jbr/booking'
|
|
40
47
|
require 'jbr/collections/jobs'
|
|
41
48
|
require 'jbr/collections/visits'
|
|
49
|
+
require 'jbr/collections/technicians'
|
|
42
50
|
require 'jbr/collections/quotes'
|
|
43
51
|
require 'jbr/collections/invoices'
|
|
44
52
|
require 'jbr/collections/customers'
|
|
@@ -49,6 +57,7 @@ require 'jbr/account'
|
|
|
49
57
|
require 'jbr/mock/jobs'
|
|
50
58
|
require 'jbr/mock/visit'
|
|
51
59
|
require 'jbr/mock/visits'
|
|
60
|
+
require 'jbr/mock/technicians'
|
|
52
61
|
require 'jbr/mock/quote'
|
|
53
62
|
require 'jbr/mock/quotes'
|
|
54
63
|
require 'jbr/mock/invoice'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jbr
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 6.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '3.0'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
39
|
+
version: '3.0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: minitest
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -125,6 +125,7 @@ files:
|
|
|
125
125
|
- lib/jbr.rb
|
|
126
126
|
- lib/jbr/account.rb
|
|
127
127
|
- lib/jbr/authorizing.rb
|
|
128
|
+
- lib/jbr/booking.rb
|
|
128
129
|
- lib/jbr/collection.rb
|
|
129
130
|
- lib/jbr/collections/customers.rb
|
|
130
131
|
- lib/jbr/collections/invoices.rb
|
|
@@ -132,6 +133,7 @@ files:
|
|
|
132
133
|
- lib/jbr/collections/leads.rb
|
|
133
134
|
- lib/jbr/collections/locations.rb
|
|
134
135
|
- lib/jbr/collections/quotes.rb
|
|
136
|
+
- lib/jbr/collections/technicians.rb
|
|
135
137
|
- lib/jbr/collections/visits.rb
|
|
136
138
|
- lib/jbr/error.rb
|
|
137
139
|
- lib/jbr/errors/refused.rb
|
|
@@ -139,6 +141,7 @@ files:
|
|
|
139
141
|
- lib/jbr/event.rb
|
|
140
142
|
- lib/jbr/includable.rb
|
|
141
143
|
- lib/jbr/listable.rb
|
|
144
|
+
- lib/jbr/logger.rb
|
|
142
145
|
- lib/jbr/mock.rb
|
|
143
146
|
- lib/jbr/mock/account.rb
|
|
144
147
|
- lib/jbr/mock/invoice.rb
|
|
@@ -147,11 +150,13 @@ files:
|
|
|
147
150
|
- lib/jbr/mock/leads.rb
|
|
148
151
|
- lib/jbr/mock/quote.rb
|
|
149
152
|
- lib/jbr/mock/quotes.rb
|
|
153
|
+
- lib/jbr/mock/technicians.rb
|
|
150
154
|
- lib/jbr/mock/visit.rb
|
|
151
155
|
- lib/jbr/mock/visits.rb
|
|
152
156
|
- lib/jbr/phone.rb
|
|
153
157
|
- lib/jbr/querying.rb
|
|
154
158
|
- lib/jbr/reader.rb
|
|
159
|
+
- lib/jbr/reading.rb
|
|
155
160
|
- lib/jbr/refreshing.rb
|
|
156
161
|
- lib/jbr/resources/customer.rb
|
|
157
162
|
- lib/jbr/resources/invoice.rb
|
|
@@ -160,7 +165,9 @@ files:
|
|
|
160
165
|
- lib/jbr/resources/line.rb
|
|
161
166
|
- lib/jbr/resources/location.rb
|
|
162
167
|
- lib/jbr/resources/quote.rb
|
|
168
|
+
- lib/jbr/resources/technician.rb
|
|
163
169
|
- lib/jbr/resources/visit.rb
|
|
170
|
+
- lib/jbr/scheduled.rb
|
|
164
171
|
- lib/jbr/token.rb
|
|
165
172
|
- lib/jbr/version.rb
|
|
166
173
|
homepage: https://github.com/HouseAccountEng/jbr
|