LP#1684988: add opt-in check to patron service
[working/Evergreen.git] / Open-ILS / web / js / ui / default / staff / services / patron_search.js
1 /**
2  * Patron Search module
3  */
4
5 angular.module('egPatronSearchMod', ['ngRoute', 'ui.bootstrap', 
6     'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
7
8 /**
9  * Patron service
10  */
11 .factory('patronSvc',
12        ['$q','$timeout','$location','egCore','egUser','egConfirmDialog','$locale',
13 function($q , $timeout , $location , egCore,  egUser , egConfirmDialog , $locale) {
14
15     var service = {
16         // cached patron search results
17         patrons : [],
18
19         // currently selected patron object
20         current : null, 
21
22         // patron circ stats (overdues, fines, holds)
23         patron_stats : null,
24
25         // event types manually overridden, which should always be
26         // overridden for checkouts to this patron for this instance of
27         // the interface.
28         checkout_overrides : {},        
29         //holds the searched barcode
30         search_barcode : null,      
31     };
32
33     // when we change the default patron, we need to clear out any
34     // data collected on that patron
35     service.resetPatronLists = function() {
36         service.checkouts = [];
37         service.items_out = []
38         service.items_out_ids = [];
39         service.holds = [];
40         service.hold_ids = [];
41         service.checkout_overrides = {};
42         service.patron_stats = null;
43         service.noncat_ids = [];
44         service.hasAlerts = false;
45         service.patronExpired = false;
46         service.patronExpiresSoon = false;
47         service.invalidAddresses = false;
48     }
49     service.resetPatronLists();  // initialize
50
51     // Returns true if the last alerted patron matches the current
52     // patron.  Otherwise, the last alerted patron is set to the 
53     // current patron and false is returned.
54     service.alertsShown = function() {
55         var key = 'eg.circ.last_alerted_patron';
56         var last_id = egCore.hatch.getSessionItem(key);
57         if (last_id && last_id == service.current.id()) return true;
58         egCore.hatch.setSessionItem(key, service.current.id());
59         return false;
60     }
61
62     // shortcut to force-reload the current primary
63     service.refreshPrimary = function() {
64         if (!service.current) return $q.when();
65         return service.setPrimary(service.current.id(), null, true);
66     }
67
68     // clear the currently focused user
69     service.clearPrimary = function() {
70         // reset with no patron
71         service.resetPatronLists();
72         service.current = null;
73         service.patron_stats = null;
74         return $q.when();
75     }
76
77     // sets the primary display user, fetching data as necessary.
78     service.setPrimary = function(id, user, force) {
79         var user_id = id ? id : (user ? user.id() : null);
80
81         console.debug('setting primary user to: ' + user_id);
82
83         if (!user_id) return $q.reject();
84
85         // when loading a new patron, update the last patron setting
86         if (!service.current || service.current.id() != user_id)
87             egCore.hatch.setLoginSessionItem('eg.circ.last_patron', user_id);
88
89         // avoid running multiple retrievals for the same patron, which
90         // can happen during dbl-click by maintaining a single running
91         // data retrieval promise
92         if (service.primaryUserPromise) {
93             if (service.primaryUserId == user_id) {
94                 return service.primaryUserPromise.promise;
95             } else {
96                 service.primaryUserPromise = null;
97             }
98         }
99
100         service.primaryUserPromise = $q.defer();
101         service.primaryUserId = user_id;
102
103         service.getPrimary(id, user, force)
104         .then(function() {
105             service.checkAlerts();
106             var p = service.primaryUserPromise;
107             service.primaryUserId = null;
108             // clear before resolution just to be safe.
109             service.primaryUserPromise = null;
110             p.resolve();
111         });
112
113         return service.primaryUserPromise.promise;
114     }
115
116     service.getPrimary = function(id, user, force) {
117
118         if (user) {
119             if (!force && service.current && 
120                 service.current.id() == user.id()) {
121                 if (service.patron_stats) {
122                     return $q.when();
123                 } else {
124                     return service.fetchUserStats();
125                 }
126             }
127
128             service.resetPatronLists();
129
130             return service.checkOptIn(user).then(
131                 function() {
132                     service.current = user;
133                     service.localFlesh(user);
134                     return service.fetchUserStats();
135                 },
136                 function() {
137                     return $q.reject();
138                 }
139             );
140
141         } else if (id) {
142             if (!force && service.current && service.current.id() == id) {
143                 if (service.patron_stats) {
144                     return $q.when();
145                 } else {
146                     return service.fetchUserStats();
147                 }
148             }
149
150             service.resetPatronLists();
151
152             return egUser.get(id).then(
153                 function(user) {
154                     return service.checkOptIn(user).then(
155                         function() {
156                             service.current = user;
157                             service.localFlesh(user);
158                             return service.fetchUserStats();
159                         },
160                         function() {
161                             return $q.reject();
162                         }
163                     );
164                 },
165                 function(err) {
166                     console.error(
167                         "unable to fetch user "+id+': '+js2JSON(err))
168                 }
169             );
170         } else {
171
172             // fetching a null user clears the primary user.
173             // NOTE: this should probably reject() and log an error, 
174             // but calling clear for backwards compat for now.
175             return service.clearPrimary();
176         }
177     }
178
179     // flesh some additional user fields locally
180     service.localFlesh = function(user) {
181         if (!angular.isObject(typeof user.home_ou()))
182             user.home_ou(egCore.org.get(user.home_ou()));
183
184         angular.forEach(
185             user.standing_penalties(),
186             function(penalty) {
187                 if (!angular.isObject(penalty.org_unit()))
188                     penalty.org_unit(egCore.org.get(penalty.org_unit()));
189             }
190         );
191
192         // stat_cat_entries == stat_cat_entry_user_map
193         angular.forEach(user.stat_cat_entries(), function(map) {
194             if (angular.isObject(map.stat_cat())) return;
195             // At page load, we only retrieve org-visible stat cats.
196             // For the common case, ignore entries for remote stat cats.
197             var cat = egCore.env.actsc.map[map.stat_cat()];
198             if (cat) {
199                 map.stat_cat(cat);
200                 cat.owner(egCore.org.get(cat.owner()));
201             }
202         });
203     }
204
205     // resolves to true if the patron account has expired or will
206     // expire soon, based on YAOUS circ.patron_expires_soon_warning
207     // note: returning a promise is no longer strictly necessary
208     // (no more async activity) if the calling function is changed too.
209     service.testExpire = function() {
210
211         var expire = Date.parse(service.current.expire_date());
212         if (expire < new Date()) {
213             return $q.when(service.patronExpired = true);
214         }
215
216         var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
217         if (Number(soon)) {
218             var preExpire = new Date();
219             preExpire.setDate(preExpire.getDate() + Number(soon));
220             if (expire < preExpire) 
221                 return $q.when(service.patronExpiresSoon = true);
222         }
223
224         return $q.when(false);
225     }
226
227     // resolves to true if the patron account has any invalid addresses.
228     service.testInvalidAddrs = function() {
229
230         if (service.invalidAddresses)
231             return $q.when(true);
232
233         var fail = false;
234
235         angular.forEach(
236             service.current.addresses(), 
237             function(addr) { if (addr.valid() == 'f') fail = true }
238         );
239
240         return $q.when(fail);
241     }
242     //resolves to true if the patron was fetched with an inactive card
243     service.fetchedWithInactiveCard = function() {
244         var bc = service.search_barcode
245         var cards = service.current.cards();
246         var card = cards.filter(function(c) { return c.barcode() == bc })[0];
247         return (card && card.active() == 'f');
248     }   
249     // resolves to true if there is any aspect of the patron account
250     // which should produce a message in the alerts panel
251     service.checkAlerts = function() {
252
253         if (service.hasAlerts) // already checked
254             return $q.when(true); 
255
256         var deferred = $q.defer();
257         var p = service.current;
258
259         if (service.alert_penalties.length ||
260             p.alert_message() ||
261             p.active() == 'f' ||
262             p.barred() == 't' ||
263             service.patron_stats.holds.ready) {
264
265             service.hasAlerts = true;
266         }
267
268         // see if the user was retrieved with an inactive card
269         if(service.fetchedWithInactiveCard()){
270             service.hasAlerts = true;
271         }
272
273         // regardless of whether we know of alerts, we still need 
274         // to test/fetch the expire data for display
275         service.testExpire().then(function(bool) {
276             if (bool) service.hasAlerts = true;
277             deferred.resolve(service.hasAlerts);
278         });
279
280         service.testInvalidAddrs().then(function(bool) {
281             if (bool) service.invalidAddresses = true;
282             deferred.resolve(service.invalidAddresses);
283         });
284
285         return deferred.promise;
286     }
287
288     service.fetchGroupFines = function() {
289         return egCore.net.request(
290             'open-ils.actor',
291             'open-ils.actor.usergroup.members.balance_owed',
292             egCore.auth.token(), service.current.usrgroup()
293         ).then(function(list) {
294             var total = 0;
295             angular.forEach(list, function(u) { 
296                 total += 100 * Number(u.balance_owed)
297             });
298             service.patron_stats.fines.group_balance_owed = total / 100;
299         });
300     }
301
302     service.getUserStats = function(id) {
303         return egCore.net.request(
304             'open-ils.actor',
305             'open-ils.actor.user.opac.vital_stats.authoritative', 
306             egCore.auth.token(), id
307         ).then(
308             function(stats) {
309                 // force numeric to ensure correct boolean handling in templates
310                 stats.fines.balance_owed = Number(stats.fines.balance_owed);
311                 stats.checkouts.overdue = Number(stats.checkouts.overdue);
312                 stats.checkouts.claims_returned = 
313                     Number(stats.checkouts.claims_returned);
314                 stats.checkouts.lost = Number(stats.checkouts.lost);
315                 stats.checkouts.out = Number(stats.checkouts.out);
316                 stats.checkouts.total_out = 
317                     stats.checkouts.out + stats.checkouts.overdue;
318
319                 stats.checkouts.total_out += Number(stats.checkouts.long_overdue);
320
321                 if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
322                     stats.checkouts.total_out += stats.checkouts.claims_returned;
323
324                 if (egCore.env.aous['circ.tally_lost'])
325                     stats.checkouts.total_out += stats.checkouts.lost
326
327                 return stats;
328             }
329         );
330     }
331
332     // Fetches the IDs of any active non-cat checkouts for the current
333     // user.  Also sets the patron_stats non_cat count value to match.
334     service.getUserNonCats = function(id) {
335         return egCore.net.request(
336             'open-ils.circ',
337             'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
338             egCore.auth.token(), id
339         ).then(function(noncat_ids) {
340             service.noncat_ids = noncat_ids;
341             service.patron_stats.checkouts.noncat = noncat_ids.length;
342         });
343     }
344
345     // grab additional circ info
346     service.fetchUserStats = function() {
347         return service.getUserStats(service.current.id())
348         .then(function(stats) {
349             service.patron_stats = stats
350             service.alert_penalties = service.current.standing_penalties()
351                 .filter(function(pen) { 
352                 return pen.standing_penalty().staff_alert() == 't' 
353             });
354
355             service.summary_stat_cats = [];
356             angular.forEach(service.current.stat_cat_entries(), 
357                 function(map) {
358                     if (angular.isObject(map.stat_cat()) &&
359                         map.stat_cat().usr_summary() == 't') {
360                         service.summary_stat_cats.push(map);
361                     }
362                 }
363             );
364
365             // run these two in parallel
366             var p1 = service.getUserNonCats(service.current.id());
367             var p2 = service.fetchGroupFines();
368             return $q.all([p1, p2]);
369         });
370     }
371
372     service.createOptIn = function(user_id) {
373         return egCore.net.request(
374             'open-ils.actor',
375             'open-ils.actor.user.org_unit_opt_in.create',
376             egCore.auth.token(), user_id);
377     }
378
379     service.checkOptIn = function(user) {
380         var deferred = $q.defer();
381         egCore.net.request(
382             'open-ils.actor',
383             'open-ils.actor.user.org_unit_opt_in.check',
384             egCore.auth.token(), user.id())
385         .then(function(optInResp) {
386             if (eg_evt = egCore.evt.parse(optInResp)) {
387                 deferred.reject();
388                 console.log('error on opt-in check: ' + eg_evt);
389             } else if (optInResp == 2) {
390                 // opt-in disallowed at this location by patron's home library
391                 deferred.reject();
392                 alert(egCore.strings.OPT_IN_RESTRICTED);
393             } else if (optInResp == 1) {
394                 // opt-in handled or not needed, do nothing
395                 deferred.resolve();
396             } else {
397                 // opt-in needed, show the opt-in dialog
398                 var org = egCore.org.get(user.home_ou());
399                 egConfirmDialog.open(
400                     egCore.strings.OPT_IN_DIALOG_TITLE,
401                     egCore.strings.OPT_IN_DIALOG,
402                     {   family_name : user.family_name(),
403                         first_given_name : user.first_given_name(),
404                         org_name : org.name(),
405                         org_shortname : org.shortname(),
406                         ok : function() {
407                             service.createOptIn(user.id())
408                             .then(function(resp) {
409                                 if (evt = egCore.evt.parse(resp)) {
410                                     deferred.reject();
411                                     alert(evt);
412                                 } else {
413                                     deferred.resolve();
414                                 }
415                             });
416                         },
417                         cancel : function() { deferred.reject(); }
418                     }
419                 );
420             }
421         });
422         return deferred.promise;
423     }
424
425     // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
426     // which is the Angular default.
427     // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
428     // FIXME: This change needs to be moved into a project-wide collection
429     // of locale overrides.
430     $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
431     $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
432
433     return service;
434 }])
435
436 /**
437  * Manages patron search
438  */
439 .controller('BasePatronSearchCtrl',
440        ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
441        '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
442        'egProgressDialog',
443 function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
444         $filter,  egUser,  patronSvc , egGridDataProvider , $document,
445         egProgressDialog) {
446
447     $scope.focusMe = true;
448     $scope.searchArgs = {
449         // default to searching globally
450         home_ou : egCore.org.tree()
451     };
452
453     // last used patron search form element
454     var lastFormElement;
455
456     $scope.gridControls = {
457         selectedItems : function() {return []}
458     }
459
460     // Handle URL-encoded searches
461     if ($location.search().search) {
462         console.log('URL search = ' + $location.search().search);
463         patronSvc.urlSearch = {search : JSON2js($location.search().search)};
464
465         // why the double-JSON encoded sort?
466         if (patronSvc.urlSearch.search.search_sort) {
467             patronSvc.urlSearch.sort = 
468                 JSON2js(patronSvc.urlSearch.search.search_sort);
469         } else {
470             patronSvc.urlSearch.sort = [];
471         }
472         delete patronSvc.urlSearch.search.search_sort;
473
474         // include inactive patrons if "inactive" param
475         if ($location.search().inactive) {
476             patronSvc.urlSearch.inactive = $location.search().inactive;
477         }
478     }
479
480     var propagate;
481     var propagate_inactive;
482     if (patronSvc.lastSearch) {
483         propagate = patronSvc.lastSearch.search;
484         // home_ou needs to be treated specially
485         propagate.home_ou = {
486             value : patronSvc.lastSearch.home_ou,
487             group : 0
488         };
489     } else if (patronSvc.urlSearch) {
490         propagate = patronSvc.urlSearch.search;
491         if (patronSvc.urlSearch.inactive) {
492             propagate_inactive = patronSvc.urlSearch.inactive;
493         }
494     }
495
496     if (egCore.env.pgt) {
497         $scope.profiles = egCore.env.pgt.list;
498     } else {
499         egCore.pcrud.search('pgt', {parent : null}, 
500             {flesh : -1, flesh_fields : {pgt : ['children']}}
501         ).then(
502             function(tree) {
503                 egCore.env.absorbTree(tree, 'pgt')
504                 $scope.profiles = egCore.env.pgt.list;
505             }
506         );
507     }
508
509     if (propagate) {
510         // populate the search form with our cached / preexisting search info
511         angular.forEach(propagate, function(val, key) {
512             if (key == 'profile')
513                 val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
514             if (key == 'home_ou')
515                 val.value = egCore.org.get(val.value);
516             $scope.searchArgs[key] = val.value;
517         });
518         if (propagate_inactive) {
519             $scope.searchArgs.inactive = propagate_inactive;
520         }
521     }
522
523     var provider = egGridDataProvider.instance({});
524
525     provider.get = function(offset, count) {
526         var deferred = $q.defer();
527
528         var fullSearch;
529         if (patronSvc.urlSearch) {
530             fullSearch = patronSvc.urlSearch;
531             // enusre the urlSearch only runs once.
532             delete patronSvc.urlSearch;
533
534         } else {
535             patronSvc.search_barcode = $scope.searchArgs.card;
536             
537             var search = compileSearch($scope.searchArgs);
538             if (Object.keys(search) == 0) return $q.when();
539
540             var home_ou = search.home_ou;
541             delete search.home_ou;
542             var inactive = search.inactive;
543             delete search.inactive;
544
545             fullSearch = {
546                 search : search,
547                 sort : compileSort(),
548                 inactive : inactive,
549                 home_ou : home_ou,
550             };
551         }
552
553         fullSearch.count = count;
554         fullSearch.offset = offset;
555
556         if (patronSvc.lastSearch) {
557             // search repeated, return the cached results
558             if (angular.equals(fullSearch, patronSvc.lastSearch)) {
559                 console.log('patron search returning ' + 
560                     patronSvc.patrons.length + ' cached results');
561                 
562                 // notify has to happen after returning the promise
563                 $timeout(
564                     function() {
565                         angular.forEach(patronSvc.patrons, function(user) {
566                             deferred.notify(user);
567                         });
568                         deferred.resolve();
569                     }
570                 );
571                 return deferred.promise;
572             }
573         }
574
575         patronSvc.lastSearch = fullSearch;
576
577         if (fullSearch.search.id) {
578             // search by user id performs a direct ID lookup
579             var userId = fullSearch.search.id.value;
580             $timeout(
581                 function() {
582                     egUser.get(userId).then(function(user) {
583                         patronSvc.localFlesh(user);
584                         patronSvc.patrons = [user];
585                         deferred.notify(user);
586                         deferred.resolve();
587                     });
588                 }
589             );
590             return deferred.promise;
591         }
592
593         if (!Object.keys(fullSearch.search).length) {
594             // Empty searches are rejected by the server.  Avoid 
595             // running the the empty search that runs on page load. 
596             return $q.when();
597         }
598
599         egProgressDialog.open(); // Indeterminate
600
601         patronSvc.patrons = [];
602         var which_sound = 'success';
603         egCore.net.request(
604             'open-ils.actor',
605             'open-ils.actor.patron.search.advanced.fleshed',
606             egCore.auth.token(), 
607             fullSearch.search, 
608             fullSearch.count,
609             fullSearch.sort,
610             fullSearch.inactive,
611             fullSearch.home_ou,
612             egUser.defaultFleshFields,
613             fullSearch.offset
614
615         ).then(
616             function() {
617                 deferred.resolve();
618             },
619             function() { // onerror
620                 which_sound = 'error';
621             },
622             function(user) {
623                 // hide progress bar as soon as the first result appears.
624                 egProgressDialog.close();
625                 patronSvc.localFlesh(user); // inline
626                 patronSvc.patrons.push(user);
627                 deferred.notify(user);
628             }
629         )['finally'](function() { // close on 0-hits or error
630             if (which_sound == 'success' && patronSvc.patrons.length == 0) {
631                 which_sound = 'warning';
632             }
633             egCore.audio.play(which_sound + '.patron.by_search');
634             egProgressDialog.close();
635         });
636
637         return deferred.promise;
638     };
639
640     $scope.patronSearchGridProvider = provider;
641
642     // determine the tree depth of the profile group
643     $scope.pgt_depth = function(grp) {
644         var d = 0;
645         while (grp = egCore.env.pgt.map[grp.parent()]) d++;
646         return d;
647     }
648
649     $scope.clearForm = function () {
650         $scope.searchArgs={};
651         if (lastFormElement) lastFormElement.focus();
652     }
653
654     $scope.applyShowExtras = function($event, bool) {
655         if (bool) {
656             $scope.showExtras = true;
657             egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
658         } else {
659             $scope.showExtras = false;
660             egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
661         }
662         if (lastFormElement) lastFormElement.focus();
663         $event.preventDefault();
664     }
665
666     egCore.hatch.getItem('eg.circ.patron.search.show_extras')
667     .then(function(val) {$scope.showExtras = val});
668
669     // map form arguments into search params
670     function compileSearch(args) {
671         var search = {};
672         angular.forEach(args, function(val, key) {
673             if (!val) return;
674             if (key == 'profile' && args.profile) {
675                 search.profile = {value : args.profile.id(), group : 0};
676             } else if (key == 'home_ou' && args.home_ou) {
677                 search.home_ou = args.home_ou.id(); // passed separately
678             } else if (key == 'inactive') {
679                 search.inactive = val;
680             } else {
681                 search[key] = {value : val, group : 0};
682             }
683             if (key.match(/phone|ident/)) {
684                 search[key].group = 2;
685             } else {
686                 if (key.match(/street|city|state|post_code/)) {
687                     search[key].group = 1;
688                 } else if (key == 'card') {
689                     search[key].group = 3
690                 }
691             }
692         });
693
694         return search;
695     }
696
697     function compileSort() {
698
699         if (!provider.sort.length) {
700             return [ // default
701                 "family_name ASC",
702                 "first_given_name ASC",
703                 "second_given_name ASC",
704                 "dob DESC"
705             ];
706         }
707
708         var sort = [];
709         angular.forEach(
710             provider.sort,
711             function(sortdef) {
712                 if (angular.isObject(sortdef)) {
713                     var name = Object.keys(sortdef)[0];
714                     var dir = sortdef[name];
715                     sort.push(name + ' ' + dir);
716                 } else {
717                     sort.push(sortdef);
718                 }
719             }
720         );
721
722         return sort;
723     }
724
725     $scope.setLastFormElement = function() {
726         lastFormElement = $document[0].activeElement;
727     }
728
729     // search form submit action; tells the results grid to
730     // refresh itself.
731     $scope.search = function(args) { // args === $scope.searchArgs
732         if (args && Object.keys(args).length) 
733             $scope.gridControls.refresh();
734         if (lastFormElement) lastFormElement.focus();
735     }
736
737     // TODO: move this into the (forthcoming) grid row activate action
738     $scope.onPatronDblClick = function($event, user) {
739         $location.path('/circ/patron/' + user.id() + '/checkout');
740     }
741
742     if (patronSvc.urlSearch) {
743         // force the grid to load the url-based search on page load
744         provider.refresh();
745     }
746
747     $scope.need_two_selected = function() {
748         var items = $scope.gridControls.selectedItems();
749         return (items.length == 2) ? false : true;
750     }
751    
752 }])
753