Fix delete an item in checkouts list; regression error
[sitka/overdrive-evergreen-opac.git] / src / od_api.coffee
CommitLineData
8ebc3fff
SC
1define [
2 'jquery'
3 'lodash'
4 'json'
5 'cookies'
6 'moment'
7 'od_config'
bf67304d
SC
8 'od_session'
9], ($, _, json, C, M, config, Session) ->
8ebc3fff
SC
10
11 # Dump the given arguments or log them to console
12 log = ->
13 try
14 dump "#{x}\n" for x in arguments
15 return
16 catch
17 console.log arguments
18 return
19
20 $notify = $ {}
21
22 logError = (jqXHR, textStatus, errorThrown) ->
23 log "#{textStatus} #{jqXHR.status} #{errorThrown}"
24 $notify.trigger 'od.fail', arguments
25
26 # Define custom event names for this module. A custom event is triggered
27 # whenever result data becomes available after making an API request.
28 eventList = [
29 'od.clientaccess'
30 'od.libraryaccount'
31 'od.metadata'
32 'od.availability'
33
34 'od.patronaccess'
35 'od.patroninfo'
36 'od.holds'
37 'od.checkouts'
38 'od.interests'
39 'od.action'
40
41 'od.hold.update'
42 'od.hold.delete'
43 'od.checkout.update'
44 'od.checkout.delete'
45
46 'od.prefs'
47 'od.login'
48 'od.logout'
49 'od.error'
50 ]
51 eventObject = $({}).on eventList.join(' '), (e, x, y...) -> log e.namespace, x, y
52
bf67304d
SC
53 # On page load, we unserialize the text string found in local storage into
54 # an object, or if there is no string yet, we create the default object.
8ebc3fff
SC
55 # The session object uses a local storage mechanism based on window.name;
56 # see
57 # http://stackoverflow.com/questions/2035075/using-window-name-as-a-local-data-cache-in-web-browsers
58 # for pros and cons and alternatives.
bf67304d 59 session = new Session window.name, Boolean C('eg_loggedin') or window.IAMXUL
8ebc3fff 60
bf67304d
SC
61 # On window unload, we serialize it into local storage so that it survives
62 # page reloads.
63 $(window).on 'unload', -> window.name = session.store()
8ebc3fff
SC
64
65 # Customize the plain jQuery ajax to post a request for an access token
66 _api = (url, data) ->
67
68 $.ajax $.extend {},
69 # The Basic Authorization string is always added to the HTTP header.
70 headers: Authorization: "Basic #{config.credentials}"
71 # The URL endpoint is converted to its reverse proxy version,
72 # because we are using the Evergreen server as a reverse proxy to
73 # the Overdrive server.
74 url: proxy url
75 type: 'POST'
76 # We expect data to be always given; the ajax method will convert
77 # it to a query string.
78 data: data
79
80 # Replace the host domain of a given URL with a proxy domain. If the input
81 # URL specifies a protocol, it is stripped out so that the output will
82 # default to the client's protocol.
83 proxy = (x) ->
84 return unless x
85 y = x
86 y = y.replace 'https://', '//'
87 y = y.replace 'http://' , '//'
88 y = y.replace '//oauth-patron.overdrive.com', '/od/oauth-patron'
89 y = y.replace '//oauth.overdrive.com', '/od/oauth'
90 y = y.replace '//patron.api.overdrive.com', '/od/api-patron'
91 y = y.replace '//api.overdrive.com', '/od/api'
92 y = y.replace '//images.contentreserve.com', '/od/images'
93 y = y.replace '//fulfill.contentreserve.com', '/od/fulfill'
94 #log "proxy #{x} -> #{y}"
95 y
96
97 # Convert a serialized array into a serialized object
98 serializeObject = (a) ->
99 o = {}
100 $.each a, ->
101 v = @value or ''
102 if (n = o[@name]) isnt undefined
103 o[@name] = [n] unless n.push
104 o[@name].push v
105 else
106 o[@name] = v
107 return o
108
109 # TODO unused
110 $.fn.extend
111
112 # Convert this serialized array to a serialized object
113 _serializeObject: -> serializeObject @serializeArray()
114
115 # Serialize this to a json string, an object, an array, a query string, or just return itself
116 _serializeX: (X) ->
117 switch X
118 when 'j' then json.stringify @_serializeX 'o'
119 when 'k' then json.stringify @_serializeX 'a'
120 when 'p' then $.param @_serializeX 'a'
121 when 's' then @serialize()
122 when 'o' then serializeObject @_serializeX 'a'
123 when 'a' then @serializeArray()
124 else @
125
126 # Mutate an ISO 8601 date string into a Moment object. If the argument is
127 # just a date value, then it specifies an absolute date in ISO 8601 format.
128 # If the argument is a pair, then it specifies a date relative to now. For
129 # an ISO 8601 date, we correct for what seems to be an error in time zone,
130 # Zulu time is really East Coast time.
131 momentize = (date, unit) ->
132 switch arguments.length
133 when 1
134 if date then M(date.replace /Z$/, '-0400') else M()
135 when 2
136 if date then M().add date, unit else M()
137 else M()
138
139 # We define the public interface of the module
140 # TODO wrap od in jquery so that we can use it to trigger events and bind event handlers
141 od =
142
143 # Povides the anchor object for implementing a publish/subscribe
144 # mechanism for this module.
145 $: eventObject.on
146
147 # Notification that there are possible changes of values from
148 # preferences page that should be updated in the session cache
bf67304d 149 'od.prefs': (ev, x) -> session.prefs.update x
8ebc3fff
SC
150
151 # Expire patron access token if user is no longer logged into EG
152 'od.logout': (ev, x) ->
153 if x is 'eg'
bf67304d 154 session = new Session() if session.token.is_patron_access()
8ebc3fff
SC
155
156 log: log
157
8ebc3fff
SC
158 proxy: proxy
159
160 # Map format id to format name using current session object
161 labels: (id) -> session.labels[id] or id
162
163 # Customize the plain jQuery ajax method to handle a GET or POST method
164 # for the Overdrive api.
165 api: (url, method, data) ->
166
167 # Do some pre-processing of data before it is sent to server
168 if method is 'post'
169
170 # Convert numberOfDays value from an ISO 8601 date string to
171 # number of days relative to now. There are two subtleties
172 # regarding rounding errors: First, we use only use now of
173 # resolution to days to avoid a local round-down from 1 to 0.
174 # Second, we need to add one to avoid a round-down at the OD
175 # server.
176 for v in data.fields when v.name is 'numberOfDays'
177 v.value = 1 + M(v.value).diff M().toArray()[0..2], 'days'
178
179 $.ajax $.extend {},
180 # The current Authorization string is always added to the HTTP header.
bf67304d 181 headers: Authorization: "#{session.token.token_type} #{session.token.access_token}"
8ebc3fff
SC
182 # The URL endpoint is converted to its reverse proxy version, because
183 # we are using the Evergreen server as a reverse proxy to the Overdrive
184 # server.
185 url: proxy url
186 # Will default to 'get' if no method string is supplied
187 type: method
188 # A given data object is expected to be in JSON format
189 contentType: 'application/json; charset=utf-8'
190 data: json.stringify data
191
192 .done ->
193
194 # For a post method, we get a data object in reply. We publish
195 # the object using an event named after the data type, eg,
196 # 'hold', 'checkout'. We can't easily recognize the data type
197 # by looking at the data, so we have to pattern match on the
198 # API URL.
199 if method is 'post'
200 if /\/holds|\/suspension/.test url
201 x = arguments[0]
202 x.holdPlacedDate = momentize x.holdPlacedDate
203 x.holdExpires = momentize x.holdExpires
204 if x.holdSuspension
205 x.holdSuspension.numberOfDays = momentize x.holdSuspension.numberOfDays, 'days'
206 od.$.triggerHandler 'od.hold.update', x
207 if /\/checkouts/.test url
208 x = arguments[0]
209 x.expires = momentize x.expires
210 od.$.triggerHandler 'od.checkout.update', x
211
212 # For a delete method, we do not get a data object in reply,
213 # thus we pattern match for the specific ID and trigger an
214 # event with the ID.
215 if method is 'delete'
216 if id = url.match /\/holds\/(.+)\/suspension$/
217 return # no relevant event
218 if id = url.match /\/holds\/(.+)$/
219 od.$.triggerHandler 'od.hold.delete', reserveId: id[1]
220 if id = url.match /\/checkouts\/(.+)$/
d2c5c0f3 221 od.$.triggerHandler 'od.checkout.delete', reserveId: id[1]
8ebc3fff
SC
222
223 .fail ->
224 od.$.triggerHandler 'od.error', [url, arguments[0]]
225 #$('<div>')._notify arguments[1].statusText, arguments[0].responseText
226
227 # Get a library access token so that we can use the Discovery API
228 apiDiscAccess: ->
229
230 ok = (x) ->
bf67304d
SC
231 # Cache the server's response object and publish it to other
232 # modules
233 session.token.update x
8ebc3fff
SC
234 od.$.triggerHandler 'od.clientaccess', x
235
236 _api session.links.token.href, grant_type: 'client_credentials'
237
238 .then ok, logError
239
240 # Use the Library Account API to get library account information,
241 # primarily the product link and the available formats. Since we
242 # schedule this call on every page load, it will also tell us if
243 # our access token has expired or not.
244 #
245 # If a retry is needed, we have to decide whether to get a library
246 # access token or a patron access token. However, getting the latter
247 # will, in the general case, require user credentials, which means we
248 # need to store the password in the browser across sessions. An
249 # alternative is to force a logout, so that the user needs to manually
250 # relogin. In effect, we would only proceed with a retry to get a
251 # library access token, but if the user has logged in, we would not.
252 #
253 apiAccount: ->
254
255 get = -> od.api session.links.libraries.href
256
257 ok = (x) ->
bf67304d
SC
258 session.links.update x
259 session.labels.update x
8ebc3fff
SC
260 od.$.triggerHandler 'od.libraryaccount', x
261 return
262
263 retry = (jqXHR) ->
264
265 # Retry if we got a 401 error code
266 if jqXHR.status is 401
267
bf67304d 268 if session.token.is_patron_access()
8ebc3fff
SC
269 # Current OD patron access token may have expired
270 od.$.triggerHandler 'od.logout', 'od'
271
272 else
273 # Renew our access token and retry the get operation
274 od.apiDiscAccess()
275 .then get, logError
276 .then ok
277
278 get().then ok, retry
279
280 # We define a two-phase sequence to get a patron access token, for example,
281 # login(credentials); do_something_involving_page_reload(); login();
282 # where credentials is an object containing username and password
283 # properties from the login form.
284 #
285 # Logging into Evergreen can proceed using either barcode or user name,
286 # but logging into Overdrive is only a natural act using barcode. In
287 # order to ensure that logging into OD with a username can proceed, we
288 # presume that EG has been logged into and, as a prelude, we get the
289 # Preferences page of the user so that we can scrape out the barcode
290 # value for logging into OD.
291 #
292 # The login sequence is associated with a cache that remembers the
293 # login response ('parameters') between login sessions. The epilogue to
294 # the login sequence is to use the Patron Information API to get URL
295 # links and templates that will allow the user to make further use of
296 # the Circulation API.
297 login: (credentials) ->
298
299 # Temporarily store the username and password from the login form
bf67304d 300 # into the session cache, and invalidate the session token so that
8ebc3fff
SC
301 # the final part of login sequence can complete.
302 if credentials
bf67304d
SC
303 session.creds.update credentials
304 session.token.update()
8ebc3fff
SC
305 od.$.triggerHandler 'od.login'
306 return
307
bf67304d 308 # Return a promise to a resolved deferredment if session token is still valid
8ebc3fff 309 # TODO is true if in staff client but shouldn't be
bf67304d 310 if session.token.is_patron_access()
8ebc3fff
SC
311 return $.Deferred().resolve().promise()
312
313 # Request OD service for a patron access token using credentials
314 # pulled from the patron's preferences page
315 login = (prefs) ->
316
317 # Define a function to cut the value corresponding to a label
318 # from prefs
319 x = (label) ->
320 r = new RegExp "#{label}<\\/td>\\s+<td.+>(.*?)<\\/td>", 'i'
321 prefs.match(r)?[1] or ''
322
323 # Retrieve values from preferences page and save them in the
324 # session cache for later reference
bf67304d 325 session.prefs.update
8ebc3fff
SC
326 barcode: x 'barcode'
327 email_address: x 'email address'
328 home_library: x 'home library'
8ebc3fff
SC
329
330 # Use barcode as username or the username that was stored in
331 # session cache (in the hope that is a barcode) or give up with
332 # a null string
bf67304d 333 un = session.prefs.barcode or session.creds.un()
8ebc3fff
SC
334
335 # Use the password that was stored in session cache or a dummy value
bf67304d 336 pw = session.creds.pw config.password_required
8ebc3fff
SC
337
338 # Remove the stored credentials from cache as soon as they are
339 # no longer needed
bf67304d 340 session.creds.update()
8ebc3fff
SC
341
342 # Determine the Open Auth scope by mapping the long name of EG
343 # home library to OD authorization name
344 scope = "websiteid:#{config.websiteID} authorizationname:#{config.authorizationname session.prefs.home_library}"
345
346 # Try to get a patron access token from OD server
347 _api session.links.patrontoken.href,
348 grant_type: 'password'
349 username: un
350 password: pw
351 password_required: config.password_required
352 scope: scope
353
354 # Complete login sequence if the session cache is invalid
355 ok = (x) ->
bf67304d 356 session.token.update x
8ebc3fff
SC
357 od.$.triggerHandler 'od.patronaccess', x
358
359 # Get patron preferences page
360 $.get '/eg/opac/myopac/prefs'
361 # Get the access token using credentials from preferences
362 .then login
363 # Update the session cache with access token
364 .then ok
365 # Update the session cache with session links
366 .then od.apiPatronInfo
367 .fail log
368
369 # TODO not used; EG catalogue is used instead
370 apiSearch: (x) ->
371 return unless x
372 od.api session.links.products.href, get, x
373
374 # TODO we can probably get away with using one normalization routine
375 # instead of using one for each type of data object, because they don't
376 # share property names.
377
378 apiMetadata: (x) ->
379 return unless x.id
380 od.api "#{session.links.products.href}/#{x.id}/metadata"
381
382 .then (y) ->
383 # Convert ID to upper case to match same case found in EG catalogue
384 y.id = y.id.toUpperCase()
385 # Provide a simplified notion of author: first name in creators
386 # list having a role of author
387 y.author = (v.name for v in y.creators when v.role is 'Author')[0] or ''
388 # Publish the metadata object
389 od.$.triggerHandler 'od.metadata', y
390 y
391
392 .fail -> od.$.triggerHandler 'od.metadata', x
393
394 apiAvailability: (x) ->
395 return unless x.id
396
397 url =
398 if (alink = session.links.availability?.href)
399 alink.replace '{crId}', x.id # Use this link if logged in
400 else
401 "#{session.links.products.href}/#{x.id}/availability"
402
403 od.api url
404
405 # Post-process the result, eg, fill in empty properties
406 .then (y) ->
407 # Normalize the result by adding zero values
408 y.copiesOwned = 0 unless y.copiesOwned
409 y.copiesAvailable = 0 unless y.copiesAvailable
410 y.numberOfHolds = 0 unless y.numberOfHolds
411
412 if y.actions?.hold
413 # The reserve ID is empty in the actions.hold.fields; we have to fill it ourselves.
414 _.where(y.actions.hold.fields, name: 'reserveId')[0].value = y.id
415 # We jam the email address from the prefs page into the fields object from the server
416 # so that the new form will display it.
bf67304d 417 if email_address = session.prefs.email_address
8ebc3fff
SC
418 _.where(y.actions.hold.fields, name: 'emailAddress')[0].value = email_address
419
420 od.$.triggerHandler 'od.availability', y
421 arguments
422
423 .fail -> od.$.triggerHandler 'od.availability', x
424
425 apiPatronInfo: ->
426 ok = (x) ->
bf67304d 427 session.links.update x
8ebc3fff
SC
428 od.$.triggerHandler 'od.patroninfo', x
429 return
430
431 od.api session.links.patrons.href
432 .then ok, logError
433
434 apiHoldsGet: (x) ->
bf67304d 435 return unless session.token.is_patron_access()
8ebc3fff
SC
436
437 od.api "#{session.links.holds.href}#{if x?.productID then x.productID else ''}"
438
439 # Post-process the result, eg, fill in empty properties, sort list,
440 # remove redundant actions or add missing actions
441 .then (y) ->
442
443 # Normalize the result by adding an empty holds list
444 xs = y.holds or []
445
446 # For each hold, convert any ISO 8601 date strings into a
447 # Moment object (at the local time zone)
448 for x in xs
449 x.holdPlacedDate = momentize x.holdPlacedDate
450 x.holdExpires = momentize x.holdExpires
451 if x.holdSuspension
452 x.holdSuspension.numberOfDays = momentize x.holdSuspension.numberOfDays, 'days'
453
454 # Count the number of holds that can be checked out now
455 y.ready = _.countBy xs, (x) -> if x.actions.checkout then 'forCheckout' else 'other'
456 y.ready.forCheckout = 0 unless y.ready.forCheckout
457
458 # Delete action to release a suspension if a hold is not
459 # suspended, because such actions are redundant
460 delete x.actions.releaseSuspension for x in xs when not x.holdSuspension
461
462 # Sort the holds list by position and placed date
463 # and sort ready holds first
464 #y.holds = _.sortBy xs, ['holdListPosition', 'holdPlacedDate']
465 y.holds = _(xs)
466 .sortBy ['holdListPosition', 'holdPlacedDate']
467 .sortBy (x) -> x.actions.checkout
468 .value()
469
470 od.$.triggerHandler 'od.holds', y
471 arguments
472
473 apiCheckoutsGet: (x) ->
bf67304d 474 return unless session.token.is_patron_access()
8ebc3fff
SC
475
476 od.api "#{session.links.checkouts.href}#{if x?.reserveID then x.reserveID else ''}"
477
478 # Post-process the result, eg, fill in empty properties, sort list,
479 # remove redundant actions or add missing actions
480 .then (y) ->
481
482 # Normalize the result by adding an empty checkouts list
483 xs = y.checkouts or []
484
485 # Convert any ISO 8601 date strings into a Moment object (at
486 # the local time zone)
487 for x in xs
488 x.expires = momentize x.expires
489
490 # Sort the checkout list by expiration date
491 y.checkouts = _.sortBy xs, 'expires'
492
493 od.$.triggerHandler 'od.checkouts', y
494 arguments
495
496 # Get a list of user's 'interests', ie, holds and checkouts
497 apiInterestsGet: ->
498 $.when(
499 od.apiHoldsGet()
500 od.apiCheckoutsGet()
501 )
502
503 # Consolidate the holds and checkouts information into an object
504 # that represents the 'interests' of the patron
505 .then (h, c) ->
506
507 # A useful condition to handle if the API calls could not
508 # be fulfilled because they are not within the scope of the
509 # current access token
510 # TODO possibly redundant or unnecessary
511 ###
512 unless h and c
513 page {}, {}
514 return
515 ###
516
517 h = h[0]
518 c = c[0]
519
520 interests =
521 nHolds: h.totalItems
522 nHoldsReady: h.ready.forCheckout
523 nCheckouts: c.totalItems
524 nCheckoutsReady: c.totalCheckouts
525 ofHolds: h.holds
526 ofCheckouts: c.checkouts
527 # The following property is a map from product ID to a hold or
528 # a checkout object, eg, interests.byID(124)
529 byID: do (hs = h.holds, cs = c.checkouts) ->
530 byID = {}
531 for v, n in hs
532 v.type = 'hold'
533 byID[v.reserveId] = v
534 for v, n in cs
535 v.type = 'checkout'
536 byID[v.reserveId] = v
537 return byID
538
539 # Publish patron's interests to all areas of the screen
540 od.$.triggerHandler 'od.interests', interests
541 return interests
542
543 return od