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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 955de83498d3d68359199efa3e7b2d54bef89040000e80085969f4577e14d80d
4
- data.tar.gz: 925c1785457c527ce3ad9dfe677a386327e70155f9895cccca6b51c278be758f
3
+ metadata.gz: cabf4447471fd3556e99cc05251c04a201f6329864d98d0650ba8506ff1dcc0a
4
+ data.tar.gz: 8149a10d060f9c265c82a482a53c3eb5646fcb744e41bd8ee136a4eb6b3b3cc5
5
5
  SHA512:
6
- metadata.gz: 61ae3885b2ab939a53f5d27088957c7b230f55e6708440e603c8dafed5b369ef75e8430dcce5635d32962652f7bd3a6dea706f84f8ad7c46d45e865e242fff9a
7
- data.tar.gz: 3945c08a563f06b43b3fd35f9aa579fa14ebeee8bc8468196a74f1cc4a38092dd429dbdfca118920f3a2f3a07e202ac9ac8a447409f9eeb042e826787d57abc8
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', '~> 4.0'
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 4.x release and stops short of 5.0.
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
- Walk the account's visits, oldest first, the same way as its jobs, or reach one by ID:
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.find 'Z2lkOi8vS'
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.job.id # => 'Z2lkOi8vSm9i', the job the stop belongs to, where it happens and for whom
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 visits to a page.
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:
@@ -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
 
@@ -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
@@ -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
- # @param account [Account] credentials to reach Jobber with.
6
- # @param includes [Hash] what to bring back beside each record, by name.
7
- # @param filter [Hash, nil] what the list was narrowed to, in the shape Jobber filters by.
8
- def initialize(account:, includes: {}, filter: nil)
9
- @account = account
10
- @includes = includes
11
- @filter = filter
12
- end
5
+ include Reading
13
6
  end
14
7
  end
@@ -19,12 +19,12 @@ module Jbr
19
19
 
20
20
  private
21
21
 
22
- def page = paged "#{FIELDS} #{selections}", PAGE
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} #{selections} }
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, oldest first, walked a page at a time.
3
- class Visits < Collection
4
- include Listable
5
-
6
- # What a visit answers with: the keys the vocabulary reads, and the job it belongs to.
7
- FIELDS = "#{Visit.node_keys.join ' '} job { id }"
8
-
9
- # Shadows Enumerable#find on purpose, the way jobs do: a visit is reached by the ID Jobber
10
- # files it under, not by asking every visit on the account whether it is the one.
11
- # @param id [String] Jobber ID of the visit.
12
- # @return [Visit, nil] nil when Jobber has no visit under that ID.
13
- def find(id)
14
- node = @account.query(one, variables: { id: id })['visit']
15
- Visit.new node: node if node
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 page = paged FIELDS, PAGE
49
+ def windowed
50
+ return if @filter&.key? :occursWithin
21
51
 
22
- def one
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 field = 'visits'
55
+ def page = paged row(fields), PAGE
31
56
 
32
- def filtered = 'VisitFilterAttributes'
57
+ def field = 'scheduledItems'
33
58
 
34
- def item(node) = Visit.new node: node
59
+ def filtered = 'ScheduledItemsFilterAttributes!'
35
60
  end
36
61
  end
@@ -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 for whose
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
- return true unless @filter
42
+ bounds = @filter&.dig :startAt
43
+ return true unless bounds
40
44
 
41
- after, before = @filter[:startAt].values_at :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
@@ -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
@@ -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
@@ -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
- # @param id [String] ID the app filed the visit under.
6
- # @return [Mock::Visit, nil] visit the app listed under that ID, nil where it listed none.
7
- def find(id)
8
- node = Jbr.mock.visits.to_a.find { |visit| visit[:id] == id }
9
- Mock::Visit.new node: node if node
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 = Jbr.mock.visits.select { |visit| scheduled? visit[:starts_at] }
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, :oauth_url,
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, or empty when the credentials are dead.
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.
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # A Ruby client for the Jobber API.
2
2
  module Jbr
3
3
  # The version of this gem.
4
- VERSION = '4.0.0'
4
+ VERSION = '6.0.0'
5
5
  end
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. One
5
- # more file of it: an address is sent without the fields a caller left blank.
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.0.0
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: '1.0'
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: '1.0'
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