]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
LP#1452950 Patron reg. clone user
[Evergreen.git] / Open-ILS / web / js / ui / default / staff / circ / patron / regctl.js
1
2 angular.module('egCoreMod')
3 // toss tihs onto egCoreMod since the page app may vary
4
5 .factory('patronRegSvc', ['$q', 'egCore', function($q, egCore) {
6
7     var service = {
8         field_doc : {},            // config.idl_field_doc
9         profiles : [],             // permission groups
10         edit_profiles : [],        // perm groups we can modify
11         sms_carriers : [],
12         user_settings : {},        // applied user settings
13         user_setting_types : {},   // config.usr_setting_type
14         opt_in_setting_types : {}, // config.usr_setting_type for event-def opt-in
15         surveys : [],
16         survey_questions : {},
17         survey_answers : {},
18         survey_responses : {},     // survey.responses for loaded patron in progress
19         stat_cats : [],
20         stat_cat_entry_maps : {},   // cat.id to selected value
21         virt_id : -1,               // virtual ID for new objects
22         init_done : false           // have we loaded our initialization data?
23     };
24
25     // launch a series of parallel data retrieval calls
26     service.init = function(scope) {
27
28         // Data loaded here only needs to be retrieved the first time this
29         // tab becomes active within the current instance of the patron app.
30         // In other words, navigating between patron tabs will not cause
31         // all of this data to be reloaded.  Navigating to a separate app
32         // and returning will cause the data to be reloaded.
33         if (service.init_done) return $q.when();
34         service.init_done = true;
35
36         return $q.all([
37             service.get_field_doc(),
38             service.get_perm_groups(),
39             service.get_ident_types(),
40             service.get_user_settings(),
41             service.get_org_settings(),
42             service.get_stat_cats(),
43             service.get_surveys(),
44             service.get_clone_user(),
45             service.get_net_access_levels()
46         ]);
47     };
48
49     service.get_clone_user = function() {
50         if (!service.clone_id) return $q.when();
51         // we could load egUser and use its get() function, but loading
52         // user.js into the standalone register UI would mean creating a
53         // new module, since egUser is not loaded into egCoreMod.  This
54         // is a lot simpler.
55         return egCore.net.request(
56             'open-ils.actor',
57             'open-ils.actor.user.fleshed.retrieve',
58             egCore.auth.token(), service.clone_id, 
59             ['billing_address', 'mailing_address'])
60         .then(function(cuser) {
61             if (e = egCore.evt.parse(cuser)) {
62                 alert(e);
63             } else {
64                 service.clone_user = cuser;
65             }
66         });
67     }
68
69     //service.check_grp_app_perm = function(grp_id) {
70
71     // determine which user groups our user is not allowed to modify
72     service.set_edit_profiles = function() {
73         var all_app_perms = [];
74         var failed_perms = [];
75
76         // extract the application permissions
77         angular.forEach(service.profiles, function(grp) {
78             if (grp.application_perm())
79                 all_app_perms.push(grp.application_perm());
80         }); 
81
82         // fill in service.edit_profiles by inspecting failed_perms
83         function traverse_grp_tree(grp, failed) {
84             failed = failed || 
85                 failed_perms.indexOf(grp.application_perm()) > -1;
86
87             if (!failed) service.edit_profiles.push(grp);
88
89             angular.forEach(
90                 service.profiles.filter( // children of grp
91                     function(p) { return p.parent() == grp.id() }),
92                 function(child) {traverse_grp_tree(child, failed)}
93             );
94         }
95
96         return egCore.perm.hasPermAt(all_app_perms, true).then(
97             function(perm_orgs) {
98                 angular.forEach(all_app_perms, function(p) {
99                     if (perm_orgs[p].length == 0)
100                         failed_perms.push(p);
101                 });
102
103                 traverse_grp_tree(egCore.env.pgt.tree);
104             }
105         );
106     }
107
108     service.has_group_link_perms = function(org_id) {
109         return egCore.perm.hasPermAt('CREATE_USER_GROUP_LINK', true)
110         .then(function(p) { return p.indexOf(org_id) > -1; });
111     }
112
113     service.get_surveys = function() {
114         var org_ids = egCore.org.fullPath(egCore.auth.user().ws_ou(), true);
115
116         return egCore.pcrud.search('asv', {
117                 owner : org_ids,
118                 start_date : {'<=' : 'now'},
119                 end_date : {'>=' : 'now'}
120             }, {   
121                 flesh : 2, 
122                 flesh_fields : {
123                     asv : ['questions'], 
124                     asvq : ['answers']
125                 }
126             }, 
127             {atomic : true}
128         ).then(function(surveys) {
129             surveys = surveys.sort(function(a,b) {
130                 return a.name() < b.name() ? -1 : 1 });
131             service.surveys = surveys;
132             angular.forEach(surveys, function(survey) {
133                 angular.forEach(survey.questions(), function(question) {
134                     service.survey_questions[question.id()] = question;
135                     angular.forEach(question.answers(), function(answer) {
136                         service.survey_answers[answer.id()] = answer;
137                     });
138                 });
139             });
140         });
141     }
142
143     service.get_stat_cats = function() {
144         return egCore.net.request(
145             'open-ils.circ',
146             'open-ils.circ.stat_cat.actor.retrieve.all',
147             egCore.auth.token(), egCore.auth.user().ws_ou()
148         ).then(function(cats) {
149             cats = cats.sort(function(a, b) {
150                 return a.name() < b.name() ? -1 : 1});
151             angular.forEach(cats, function(cat) {
152                 cat.entries(
153                     cat.entries().sort(function(a,b) {
154                         return a.value() < b.value() ? -1 : 1
155                     })
156                 );
157             });
158             service.stat_cats = cats;
159         });
160     };
161
162     service.get_org_settings = function() {
163         return egCore.org.settings([
164             'global.password_regex',
165             'global.juvenile_age_threshold',
166             'patron.password.use_phone',
167             'ui.patron.default_inet_access_level',
168             'ui.patron.default_ident_type',
169             'ui.patron.default_country',
170             'ui.patron.registration.require_address',
171             'circ.holds.behind_desk_pickup_supported',
172             'circ.patron_edit.clone.copy_address',
173             'ui.patron.edit.au.prefix.require',
174             'ui.patron.edit.au.prefix.show',
175             'ui.patron.edit.au.prefix.suggest',
176             'ui.patron.edit.ac.barcode.regex',
177             'ui.patron.edit.au.second_given_name.show',
178             'ui.patron.edit.au.second_given_name.suggest',
179             'ui.patron.edit.au.suffix.show',
180             'ui.patron.edit.au.suffix.suggest',
181             'ui.patron.edit.au.alias.show',
182             'ui.patron.edit.au.alias.suggest',
183             'ui.patron.edit.au.dob.require',
184             'ui.patron.edit.au.dob.show',
185             'ui.patron.edit.au.dob.suggest',
186             'ui.patron.edit.au.dob.calendar',
187             'ui.patron.edit.au.juvenile.show',
188             'ui.patron.edit.au.juvenile.suggest',
189             'ui.patron.edit.au.ident_value.show',
190             'ui.patron.edit.au.ident_value.suggest',
191             'ui.patron.edit.au.ident_value2.show',
192             'ui.patron.edit.au.ident_value2.suggest',
193             'ui.patron.edit.au.email.require',
194             'ui.patron.edit.au.email.show',
195             'ui.patron.edit.au.email.suggest',
196             'ui.patron.edit.au.email.regex',
197             'ui.patron.edit.au.email.example',
198             'ui.patron.edit.au.day_phone.require',
199             'ui.patron.edit.au.day_phone.show',
200             'ui.patron.edit.au.day_phone.suggest',
201             'ui.patron.edit.au.day_phone.regex',
202             'ui.patron.edit.au.day_phone.example',
203             'ui.patron.edit.au.evening_phone.require',
204             'ui.patron.edit.au.evening_phone.show',
205             'ui.patron.edit.au.evening_phone.suggest',
206             'ui.patron.edit.au.evening_phone.regex',
207             'ui.patron.edit.au.evening_phone.example',
208             'ui.patron.edit.au.other_phone.require',
209             'ui.patron.edit.au.other_phone.show',
210             'ui.patron.edit.au.other_phone.suggest',
211             'ui.patron.edit.au.other_phone.regex',
212             'ui.patron.edit.au.other_phone.example',
213             'ui.patron.edit.phone.regex',
214             'ui.patron.edit.phone.example',
215             'ui.patron.edit.au.active.show',
216             'ui.patron.edit.au.active.suggest',
217             'ui.patron.edit.au.barred.show',
218             'ui.patron.edit.au.barred.suggest',
219             'ui.patron.edit.au.master_account.show',
220             'ui.patron.edit.au.master_account.suggest',
221             'ui.patron.edit.au.claims_returned_count.show',
222             'ui.patron.edit.au.claims_returned_count.suggest',
223             'ui.patron.edit.au.claims_never_checked_out_count.show',
224             'ui.patron.edit.au.claims_never_checked_out_count.suggest',
225             'ui.patron.edit.au.alert_message.show',
226             'ui.patron.edit.au.alert_message.suggest',
227             'ui.patron.edit.aua.post_code.regex',
228             'ui.patron.edit.aua.post_code.example',
229             'ui.patron.edit.aua.county.require',
230             'format.date',
231             'ui.patron.edit.default_suggested',
232             'opac.barcode_regex',
233             'opac.username_regex',
234             'sms.enable',
235             'ui.patron.edit.aua.state.require',
236             'ui.patron.edit.aua.state.suggest',
237             'ui.patron.edit.aua.state.show'
238         ]).then(function(settings) {
239             service.org_settings = settings;
240             return service.process_org_settings(settings);
241         });
242     };
243
244     // some org settings require the retrieval of additional data
245     service.process_org_settings = function(settings) {
246
247         var promises = [];
248
249         if (settings['sms.enable']) {
250             // fetch SMS carriers
251             promises.push(
252                 egCore.pcrud.search('csc', 
253                     {active: 'true'}, 
254                     {'order_by':[
255                         {'class':'csc', 'field':'name'},
256                         {'class':'csc', 'field':'region'}
257                     ]}, {atomic : true}
258                 ).then(function(carriers) {
259                     service.sms_carriers = carriers;
260                 })
261             );
262         } else {
263             // if other promises are added below, this is not necessary.
264             promises.push($q.when());  
265         }
266
267         // other post-org-settings processing goes here,
268         // adding to promises as needed.
269
270         return $q.all(promises);
271     };
272
273     service.get_ident_types = function() {
274         if (egCore.env.cit) {
275             service.ident_types = egCore.env.cit.list;
276             return $q.when();
277         } else {
278             return egCore.pcrud.retrieveAll('cit', {}, {atomic : true})
279             .then(function(types) { 
280                 egCore.env.absorbList(types, 'cit')
281                 service.ident_types = types 
282             });
283         }
284     };
285
286     service.get_net_access_levels = function() {
287         if (egCore.env.cnal) {
288             service.net_access_levels = egCore.env.cnal.list;
289             return $q.when();
290         } else {
291             return egCore.pcrud.retrieveAll('cnal', {}, {atomic : true})
292             .then(function(levels) { 
293                 egCore.env.absorbList(levels, 'cnal')
294                 service.net_access_levels = levels 
295             });
296         }
297     }
298
299     service.get_perm_groups = function() {
300         if (egCore.env.pgt) {
301             service.profiles = egCore.env.pgt.list;
302             return service.set_edit_profiles();
303         } else {
304             return egCore.pcrud.search('pgt', {parent : null}, 
305                 {flesh : -1, flesh_fields : {pgt : ['children']}}
306             ).then(
307                 function(tree) {
308                     egCore.env.absorbTree(tree, 'pgt')
309                     service.profiles = egCore.env.pgt.list;
310                     return service.set_edit_profiles();
311                 }
312             );
313         }
314     }
315
316     service.get_field_doc = function() {
317         return egCore.pcrud.search('fdoc', {
318             fm_class: ['au', 'ac', 'aua', 'actsc', 'asv', 'asvq', 'asva']})
319         .then(null, null, function(doc) {
320             if (!service.field_doc[doc.fm_class()]) {
321                 service.field_doc[doc.fm_class()] = {};
322             }
323             service.field_doc[doc.fm_class()][doc.field()] = doc;
324         });
325     };
326
327     service.get_user_settings = function() {
328         var org_ids = egCore.org.ancestors(egCore.auth.user().ws_ou(), true);
329
330         var static_types = [
331             'circ.holds_behind_desk', 
332             'circ.collections.exempt', 
333             'opac.hold_notify', 
334             'opac.default_phone', 
335             'opac.default_pickup_location', 
336             'opac.default_sms_carrier', 
337             'opac.default_sms_notify'];
338
339         return egCore.pcrud.search('cust', {
340             '-or' : [
341                 {name : static_types}, // common user settings
342                 {name : { // opt-in notification user settings
343                     'in': {
344                         select : {atevdef : ['opt_in_setting']}, 
345                         from : 'atevdef',
346                         // we only care about opt-in settings for 
347                         // event_defs our users encounter
348                         where : {'+atevdef' : {owner : org_ids}}
349                     }
350                 }}
351             ]
352         }, {}, {atomic : true}).then(function(setting_types) {
353
354             angular.forEach(setting_types, function(stype) {
355                 service.user_setting_types[stype.name()] = stype;
356                 if (static_types.indexOf(stype.name()) == -1) {
357                     service.opt_in_setting_types[stype.name()] = stype;
358                 }
359             });
360
361             if (service.patron_id) {
362                 // retrieve applied values for the current user 
363                 // for the setting types we care about.
364
365                 var setting_names = 
366                     setting_types.map(function(obj) { return obj.name() });
367
368                 return egCore.net.request(
369                     'open-ils.actor', 
370                     'open-ils.actor.patron.settings.retrieve.authoritative',
371                     egCore.auth.token(),
372                     service.patron_id,
373                     setting_names
374                 ).then(function(settings) {
375                     service.user_settings = settings;
376                 });
377             } else {
378
379                 // apply default user setting values
380                 angular.forEach(setting_types, function(stype, index) {
381                     if (stype.reg_default() != undefined) {
382                         service.user_settings[setting.name()] = 
383                             setting.reg_default();
384                     }
385                 });
386             }
387         });
388     }
389
390     service.invalidate_field = function(patron, field) {
391         console.log('Invalidating patron field ' + field);
392
393         return egCore.net.request(
394             'open-ils.actor',
395             'open-ils.actor.invalidate.' + field,
396             egCore.auth.token(), patron.id, null, patron.home_ou.id()
397
398         ).then(function(res) {
399             // clear the invalid value from the form
400             patron[field] = '';
401
402             // update last_xact_id so future save operations
403             // on this patron will be allowed
404             patron.last_xact_id = res.payload.last_xact_id[patron.id];
405         });
406     }
407
408     service.dupe_patron_search = function(patron, type, value) {
409         var search;
410
411         console.log('Dupe search called with "' + 
412             type +"' and value " + value);
413
414         switch (type) {
415
416             case 'name':
417                 var fname = patron.first_given_name;   
418                 var lname = patron.family_name;   
419                 if (!(fname && lname)) return;
420                 search = {
421                     first_given_name : {value : fname, group : 0},
422                     family_name : {value : lname, group : 0}
423                 };
424                 break;
425
426             case 'email':
427                 search = {email : {value : value, group : 0}};
428                 break;
429
430             case 'ident':
431                 search = {ident : {value : value, group : 2}};
432                 break;
433
434             case 'phone':
435                 search = {phone : {value : value, group : 2}};
436                 break;
437
438             case 'address':
439                 search = {};
440                 angular.forEach(['street1', 'street2', 'city', 'post_code'],
441                     function(field) {
442                         if(value[field])
443                             search[field] = {value : value[field], group: 1};
444                     }
445                 );
446                 break;
447         }
448
449         return egCore.net.request( 
450             'open-ils.actor', 
451             'open-ils.actor.patron.search.advanced',
452             egCore.auth.token(), search, null, null, 1
453         ).then(function(res) {
454             res = res.filter(function(id) {return id != patron.id});
455             return {
456                 count : res.length,
457                 search : search
458             };
459         });
460     }
461
462     service.init_patron = function(current) {
463
464         if (!current)
465             return service.init_new_patron();
466
467         service.patron = current;
468         return service.init_existing_patron(current)
469     }
470
471     service.ingest_address = function(patron, addr) {
472         addr.valid = addr.valid == 't';
473         addr.within_city_limits = addr.within_city_limits == 't';
474         addr._is_mailing = (patron.mailing_address && 
475             addr.id == patron.mailing_address.id);
476         addr._is_billing = (patron.billing_address && 
477             addr.id == patron.billing_address.id);
478     }
479
480     /*
481      * Existing patron objects reqire some data munging before insertion
482      * into the scope.
483      *
484      * 1. Turn everything into a hash
485      * 2. ... Except certain fields (selectors) whose widgets require objects
486      * 3. Bools must be Boolean, not t/f.
487      */
488     service.init_existing_patron = function(current) {
489
490         var patron = egCore.idl.toHash(current);
491
492         patron.home_ou = egCore.org.get(patron.home_ou.id);
493         patron.expire_date = new Date(Date.parse(patron.expire_date));
494         patron.dob = patron.dob ?
495             new Date(Date.parse(patron.dob)) : null;
496         patron.profile = current.profile(); // pre-hash version
497         patron.net_access_level = current.net_access_level();
498         patron.ident_type = current.ident_type();
499         patron.groups = current.groups(); // pre-hash
500
501         angular.forEach(
502             ['juvenile', 'barred', 'active', 'master_account'],
503             function(field) { patron[field] = patron[field] == 't'; }
504         );
505
506         angular.forEach(patron.cards, function(card) {
507             card.active = card.active == 't';
508             if (card.id == patron.card.id) {
509                 patron.card = card;
510                 card._primary = 'on';
511             }
512         });
513
514         angular.forEach(patron.addresses, 
515             function(addr) { service.ingest_address(patron, addr) });
516
517         // toss entries for existing stat cat maps into our living 
518         // stat cat entry map, which is modified within the template.
519         angular.forEach(patron.stat_cat_entries, function(map) {
520             service.stat_cat_entry_maps[map.stat_cat.id] = map.stat_cat_entry;
521         });
522
523         return patron;
524     }
525
526     service.init_new_patron = function() {
527         var addr = {
528             id : service.virt_id--,
529             isnew : true,
530             valid : true,
531             address_type : egCore.strings.REG_ADDR_TYPE,
532             _is_mailing : true,
533             _is_billing : true,
534             within_city_limits : false,
535             stat_cat_entries : []
536         };
537
538         var card = {
539             id : service.virt_id--,
540             isnew : true,
541             active : true,
542             _primary : 'on'
543         };
544
545         var user = {
546             isnew : true,
547             active : true,
548             card : card,
549             cards : [card],
550             home_ou : egCore.org.get(egCore.auth.user().ws_ou()),
551             addresses : [addr]
552         };
553
554         if (service.clone_user)
555             service.copy_clone_data(user);
556
557         return user;
558     }
559
560     // copy select values from the cloned user to the new user.
561     // user is a hash
562     service.copy_clone_data = function(user) {
563         var clone_user = service.clone_user;
564
565         // flesh the home org locally
566         user.home_ou = egCore.org.get(clone_user.home_ou());
567
568         if (!clone_user.billing_address() &&
569             !clone_user.mailing_address())
570             return; // no addresses to copy or link
571
572         // if the cloned user has any addresses, we don't need 
573         // the stub address created in init_new_patron.
574         user.addresses = [];
575
576         var copy_addresses = 
577             service.org_settings['circ.patron_edit.clone.copy_address'];
578
579         var clone_fields = [
580             'day_phone',
581             'evening_phone',
582             'other_phone',
583             'usrgroup'
584         ]; 
585
586         angular.forEach(clone_fields, function(field) {
587             user[field] = clone_user[field]();
588         });
589
590         if (copy_addresses) {
591             var bill_addr, mail_addr;
592
593             // copy the billing and mailing addresses into new addresses
594             function clone_addr(addr) {
595                 var new_addr = egCore.idl.toHash(addr);
596                 new_addr.id = service.virt_id--;
597                 new_addr.usr = user.id;
598                 new_addr.isnew = true;
599                 new_addr.valid = true;
600                 user.addresses.push(new_addr);
601                 return new_addr;
602             }
603
604             if (bill_addr = clone_user.billing_address()) {
605                 var addr = clone_addr(bill_addr);
606                 addr._is_billing = true;
607                 user.billing_address = addr;
608             }
609
610             if (mail_addr = clone_user.mailing_address()) {
611
612                 if (bill_addr && bill_addr.id() == mail_addr.id()) {
613                     user.mailing_address = user.billing_address;
614                     user.mailing_address._is_mailing = true;
615                 } else {
616                     var addr = clone_addr(mail_addr);
617                     addr._is_mailing = true;
618                     user.mailing_address = addr;
619                 }
620
621                 if (!bill_addr) {
622                     // if there is no billing addr, use the mailing addr
623                     user.billing_address = user.mailing_address;
624                     user.billing_address._is_billing = true;
625                 }
626             }
627
628
629         } else {
630
631             // link the billing and mailing addresses
632             var addr;
633             if (addr = clone_user.billing_address()) {
634                 user.billing_address = egCore.idl.toHash(addr);
635                 user.billing_address._is_billing = true;
636                 user.addresses.push(user.billing_address);
637                 user.billing_address._linked_owner_id = clone_user.id();
638                 // TODO: see note above about egUser, which has its
639                 // own name formatting function.
640                 user.billing_address._linked_owner =
641                     clone_user.family_name() + ', ' + 
642                     clone_user.first_given_name() + ' ' + 
643                     clone_user.second_given_name();
644             }
645
646             if (addr = clone_user.mailing_address()) {
647                 if (user.billing_address && 
648                     addr.id() == user.billing_address.id) {
649                     // mailing matches billing
650                     user.mailing_address = user.billing_address;
651                     user.mailing_address._is_mailing = true;
652                 } else {
653                     user.mailing_address = egCore.idl.toHash(addr);
654                     user.mailing_address._is_mailing = true;
655                     user.addresses.push(user.mailing_address);
656                     user.mailing_address._linked_owner_id = clone_user.id();
657                     // TODO: see note above about egUser, which has its
658                     // own name formatting function.
659                     user.mailing_address._linked_owner =
660                         clone_user.family_name() + ', ' + 
661                         clone_user.first_given_name() + ' ' + 
662                         clone_user.second_given_name();
663                 }
664             }
665         }
666     }
667
668     // translate the patron back into IDL form
669     service.save_user = function(phash) {
670
671         var patron = egCore.idl.fromHash('au', phash);
672
673         patron.home_ou(patron.home_ou().id());
674         patron.expire_date(patron.expire_date().toISOString());
675         patron.profile(patron.profile().id());
676         if (patron.dob()) 
677             patron.dob(patron.dob().toISOString().replace(/T.*/,''));
678         if (patron.ident_type()) 
679             patron.ident_type(patron.ident_type().id());
680         if (patron.net_access_level())
681             patron.net_access_level(patron.net_access_level().id());
682
683         angular.forEach(
684             ['juvenile', 'barred', 'active', 'master_account'],
685             function(field) { patron[field](phash[field] ? 't' : 'f'); }
686         );
687
688         var card_hashes = patron.cards();
689         patron.cards([]);
690         angular.forEach(card_hashes, function(chash) {
691             var card = egCore.idl.fromHash('ac', chash)
692             card.usr(patron.id());
693             card.active(chash.active ? 't' : 'f');
694             patron.cards().push(card);
695             if (chash._primary) {
696                 patron.card(card);
697             }
698         });
699
700         var addr_hashes = patron.addresses();
701         patron.addresses([]);
702         angular.forEach(addr_hashes, function(addr_hash) {
703             if (!addr_hash.isnew && !addr_hash.isdeleted) 
704                 addr_hash.ischanged = true;
705             var addr = egCore.idl.fromHash('aua', addr_hash);
706             patron.addresses().push(addr);
707             addr.valid(addr.valid() ? 't' : 'f');
708             addr.within_city_limits(addr.within_city_limits() ? 't' : 'f');
709             if (addr_hash._is_mailing) patron.mailing_address(addr);
710             if (addr_hash._is_billing) patron.billing_address(addr);
711         });
712
713         patron.survey_responses([]);
714         angular.forEach(service.survey_responses, function(answer) {
715             var question = service.survey_questions[answer.question()];
716             var resp = new egCore.idl.asvr();
717             resp.isnew(true);
718             resp.survey(question.survey());
719             resp.question(question.id());
720             resp.answer(answer.id());
721             resp.usr(patron.id());
722             resp.answer_date('now');
723             patron.survey_responses().push(resp);
724         });
725         
726         // re-object-ify the patron stat cat entry maps
727         var maps = [];
728         angular.forEach(patron.stat_cat_entries(), function(entry) {
729             var e = egCore.idl.fromHash('actscecm', entry);
730             e.stat_cat(e.stat_cat().id);
731             maps.push(e);
732         });
733         patron.stat_cat_entries(maps);
734
735         // service.stat_cat_entry_maps maps stats to values
736         // patron.stat_cat_entries is an array of stat_cat_entry_usr_map's
737         angular.forEach(
738             service.stat_cat_entry_maps, function(value, cat_id) {
739
740             // see if we already have a mapping for this entry
741             var existing = patron.stat_cat_entries().filter(
742                 function(e) { return e.stat_cat() == cat_id })[0];
743
744             if (existing) { // we have a mapping
745                 // if the existing mapping matches the new one,
746                 // there' nothing left to do
747                 if (existing.stat_cat_entry() == value) return;
748
749                 // mappings differ.  delete the old one and create
750                 // a new one below.
751                 existing.isdeleted(true);
752             }
753
754             var newmap = new egCore.idl.actscecm();
755             newmap.target_usr(patron.id());
756             newmap.isnew(true);
757             newmap.stat_cat(cat_id);
758             newmap.stat_cat_entry(value);
759             patron.stat_cat_entries().push(newmap);
760         });
761
762         if (!patron.isnew()) patron.ischanged(true);
763
764         return egCore.net.request(
765             'open-ils.actor', 
766             'open-ils.actor.patron.update',
767             egCore.auth.token(), patron);
768     }
769
770     service.save_user_settings = function(new_user, user_settings) {
771         // user_settings contains the values from the scope/form.
772         // service.user_settings contain the values from page load time.
773
774         var settings = {};
775         if (service.patron_id) {
776             // only update modified settings for existing patrons
777             angular.forEach(user_settings, function(val, key) {
778                 if (val !== service.user_settings[key])
779                     settings[key] = val;
780             });
781
782         } else {
783             // all non-null setting values are updated for new patrons
784             angular.forEach(user_settings, function(val, key) {
785                 if (val !== null) settings[key] = val;
786             });
787         }
788
789         if (Object.keys(settings).length == 0) return $q.when();
790
791         return egCore.net.request(
792             'open-ils.actor',
793             'open-ils.actor.patron.settings.update',
794             egCore.auth.token(), new_user.id(), settings
795         ).then(function(resp) {
796             console.log('settings returned ' + resp);
797             return resp;
798         });
799     }
800
801     return service;
802 }]);
803
804
805 function PatronRegCtrl($scope, $routeParams, 
806     $q, $modal, $window, egCore, patronSvc, patronRegSvc, egUnloadPrompt) {
807
808     $scope.page_data_loaded = false;
809     $scope.clone_id = patronRegSvc.clone_id = $routeParams.clone_id;
810     $scope.stage_username = $routeParams.stage_username;
811     $scope.patron_id = 
812         patronRegSvc.patron_id = $routeParams.edit_id || $routeParams.id;
813
814     // for existing patrons, disable barcode input by default
815     $scope.disable_bc = $scope.focus_usrname = Boolean($scope.patron_id);
816     $scope.focus_bc = !Boolean($scope.patron_id);
817     $scope.dupe_counts = {};
818
819     if (!$scope.edit_passthru) {
820         // in edit more, scope.edit_passthru is delivered to us by
821         // the enclosing controller.  In register mode, there is 
822         // no enclosing controller, so we create our own.
823         $scope.edit_passthru = {};
824     }
825
826     // 0=all, 1=suggested, 2=all
827     $scope.edit_passthru.vis_level = 0; 
828     // TODO: add save/clone handlers here
829
830     $scope.field_modified = function() {
831         // Call attach with every field change, regardless of whether
832         // it's been called before.  This will allow for re-attach after
833         // the user clicks through the unload warning. egUnloadPrompt
834         // will ensure we only attach once.
835         egUnloadPrompt.attach($scope);
836     }
837
838     // Apply default values for new patrons during initial registration
839     // prs is shorthand for patronSvc
840     function set_new_patron_defaults(prs) {
841         $scope.generate_password();
842         $scope.hold_notify_phone = true;
843         $scope.hold_notify_email = true;
844
845         if (prs.org_settings['ui.patron.default_ident_type']) {
846             // $scope.patron needs this field to be an object
847             var id = prs.org_settings['ui.patron.default_ident_type'];
848             var ident_type = $scope.ident_types.filter(
849                 function(type) { return type.id() == id })[0];
850             $scope.patron.ident_type = ident_type;
851         }
852         if (prs.org_settings['ui.patron.default_inet_access_level']) {
853             // $scope.patron needs this field to be an object
854             var id = prs.org_settings['ui.patron.default_inet_access_level'];
855             var level = $scope.net_access_levels.filter(
856                 function(lvl) { return lvl.id() == id })[0];
857             $scope.patron.net_access_level = level;
858         }
859         if (prs.org_settings['ui.patron.default_country']) {
860             $scope.patron.addresses[0].country = 
861                 prs.org_settings['ui.patron.default_country'];
862         }
863     }
864
865     function handle_home_org_changed() {
866         org_id = $scope.patron.home_ou.id();
867
868         patronRegSvc.has_group_link_perms(org_id)
869         .then(function(bool) {$scope.has_group_link_perm = bool});
870     }
871
872     $q.all([
873
874         $scope.initTab ? // initTab comes from patron app
875             $scope.initTab('edit', $routeParams.id) : $q.when(),
876
877         patronRegSvc.init()
878
879     ]).then(function() {
880         // called after initTab and patronRegSvc.init have completed
881
882         var prs = patronRegSvc; // brevity
883         // in standalone mode, we have no patronSvc
884         $scope.patron = prs.init_patron(patronSvc ? patronSvc.current : null);
885         $scope.field_doc = prs.field_doc;
886         $scope.edit_profiles = prs.edit_profiles;
887         $scope.ident_types = prs.ident_types;
888         $scope.net_access_levels = prs.net_access_levels;
889         $scope.user_setting_types = prs.user_setting_types;
890         $scope.opt_in_setting_types = prs.opt_in_setting_types;
891         $scope.org_settings = prs.org_settings;
892         $scope.sms_carriers = prs.sms_carriers;
893         $scope.stat_cats = prs.stat_cats;
894         $scope.surveys = prs.surveys;
895         $scope.survey_responses = prs.survey_responses;
896         $scope.stat_cat_entry_maps = prs.stat_cat_entry_maps;
897
898         $scope.user_settings = prs.user_settings;
899         // clone the user settings back into the patronRegSvc so
900         // we have a copy of the original state of the settings.
901         prs.user_settings = {};
902         angular.forEach($scope.user_settings, function(val, key) {
903             prs.user_settings[key] = val;
904         });
905
906         extract_hold_notify();
907         handle_home_org_changed();
908
909         if ($scope.org_settings['ui.patron.edit.default_suggested'])
910             $scope.edit_passthru.vis_level = 1;
911
912         if ($scope.patron.isnew) 
913             set_new_patron_defaults(prs);
914
915         $scope.page_data_loaded = true;
916
917     });
918
919     // update the currently displayed field documentation
920     $scope.set_selected_field_doc = function(cls, field) {
921         $scope.selected_field_doc = $scope.field_doc[cls][field];
922     }
923
924     // returns the tree depth of the selected profile group tree node.
925     $scope.pgt_depth = function(grp) {
926         var d = 0;
927         while (grp = egCore.env.pgt.map[grp.parent()]) d++;
928         return d;
929     }
930
931     // IDL fields used for labels in the UI.
932     $scope.idl_fields = {
933         au  : egCore.idl.classes.au.field_map,
934         ac  : egCore.idl.classes.ac.field_map,
935         aua : egCore.idl.classes.aua.field_map
936     };
937
938     // field visibility cache.  Some fields are universally required.
939     var field_visibility = {
940         'ac.barcode' : 2,
941         'au.usrname' : 2,
942         'au.passwd' :  2,
943         // TODO passwd2 2,
944         'au.first_given_name' : 2,
945         'au.family_name' : 2,
946         'au.ident_type' : 2,
947         'au.home_ou' : 2,
948         'au.profile' : 2,
949         'au.expire_date' : 2,
950         'au.net_access_level' : 2,
951         'aua.address_type' : 2,
952         'aua.post_code' : 2,
953         'aua.street1' : 2,
954         'aua.street2' : 2,
955         'aua.city' : 2,
956         'aua.county' : 2,
957         'aua.state' : 2,
958         'aua.country' : 2,
959         'aua.valid' : 2,
960         'aua.within_city_limits' : 2,
961         'stat_cats' : 1,
962         'surveys' : 1
963     }; 
964
965     // returns true if the selected field should be visible
966     // given the current required/suggested/all setting.
967     $scope.show_field = function(field_key) {
968
969         if (field_visibility[field_key] == undefined) {
970             // compile and cache the visibility for the selected field
971
972             // org settings have not been received yet.
973             if (!$scope.org_settings) return false;
974
975             var req_set = 'ui.patron.edit.' + field_key + '.require';
976             var sho_set = 'ui.patron.edit.' + field_key + '.show';
977             var sug_set = 'ui.patron.edit.' + field_key + '.suggest';
978
979             if ($scope.org_settings[req_set]) {
980                 field_visibility[field_key] = 2;
981             } else if ($scope.org_settings[sho_set]) {
982                 field_visibility[field_key] = 2;
983             } else if ($scope.org_settings[sug_set]) {
984                 field_visibility[field_key] = 1;
985             } else {
986                 field_visibility[field_key] = 0;
987             }
988         }
989
990         return field_visibility[field_key] >= $scope.edit_passthru.vis_level;
991     }
992
993     // generates a random 4-digit password
994     $scope.generate_password = function() {
995         $scope.patron.passwd = Math.floor(Math.random()*9000) + 1000;
996     }
997
998     $scope.set_expire_date = function() {
999         if (!$scope.patron.profile) return;
1000         var seconds = egCore.date.intervalToSeconds(
1001             $scope.patron.profile.perm_interval());
1002         var now_epoch = new Date().getTime();
1003         $scope.patron.expire_date = new Date(
1004             now_epoch + (seconds * 1000 /* milliseconds */))
1005     }
1006
1007     // grp is the pgt object
1008     $scope.set_profile = function(grp) {
1009         $scope.patron.profile = grp;
1010         $scope.set_expire_date();
1011         $scope.field_modified();
1012     }
1013
1014     $scope.new_address = function() {
1015         var addr = egCore.idl.toHash(new egCore.idl.aua());
1016         patronRegSvc.ingest_address($scope.patron, addr);
1017         addr.id = patronRegSvc.virt_id--;
1018         addr.isnew = true;
1019         addr.valid = true;
1020         addr.within_city_limits = true;
1021         $scope.patron.addresses.push(addr);
1022     }
1023
1024     // keep deleted addresses out of the patron object so
1025     // they won't appear in the UI.  They'll be re-inserted
1026     // when the patron is updated.
1027     deleted_addresses = [];
1028     $scope.delete_address = function(id) {
1029         var addresses = [];
1030         angular.forEach($scope.patron.addresses, function(addr) {
1031             if (addr.id == id) {
1032                 if (id > 0) {
1033                     addr.isdeleted = true;
1034                     deleted_addresses.push(addr);
1035                 }
1036             } else {
1037                 addresses.push(addr);
1038             }
1039         });
1040         $scope.patron.addresses = addresses;
1041     } 
1042
1043     $scope.post_code_changed = function(addr) { 
1044         egCore.net.request(
1045             'open-ils.search', 'open-ils.search.zip', addr.post_code)
1046         .then(function(resp) {
1047             if (!resp) return;
1048             if (resp.city) addr.city = resp.city;
1049             if (resp.state) addr.state = resp.state;
1050             if (resp.county) addr.county = resp.county;
1051             if (resp.alert) alert(resp.alert);
1052         });
1053     }
1054
1055     $scope.replace_card = function() {
1056         $scope.patron.card.active = false;
1057         $scope.patron.card.ischanged = true;
1058         $scope.disable_bc = false;
1059
1060         var new_card = egCore.idl.toHash(new egCore.idl.ac());
1061         new_card.id = patronRegSvc.virt_id--;
1062         new_card.isnew = true;
1063         new_card.active = true;
1064         new_card._primary = 'on';
1065         $scope.patron.card = new_card;
1066         $scope.patron.cards.push(new_card);
1067     }
1068
1069     $scope.day_phone_changed = function(phone) {
1070         if (phone && $scope.patron.isnew && 
1071             $scope.org_settings['patron.password.use_phone']) {
1072             $scope.patron.passwd = phone.substr(-4);
1073         }
1074     }
1075
1076     $scope.barcode_changed = function(bc) {
1077         if (!bc) return;
1078         egCore.net.request(
1079             'open-ils.actor',
1080             'open-ils.actor.barcode.exists',
1081             egCore.auth.token(), bc
1082         ).then(function(resp) {
1083             if (resp == '1') {
1084                 console.log('duplicate barcode detected: ' + bc);
1085                 // DUPLICATE CARD
1086             } else {
1087                 if (!$scope.patron.usrname)
1088                     $scope.patron.usrname = bc;
1089                 // No dupe -- A-OK
1090             }
1091         });
1092     }
1093
1094     $scope.cards_dialog = function() {
1095         $modal.open({
1096             templateUrl: './circ/patron/t_patron_cards_dialog',
1097             controller: 
1098                    ['$scope','$modalInstance','cards',
1099             function($scope , $modalInstance , cards) {
1100                 // scope here is the modal-level scope
1101                 $scope.args = {cards : cards};
1102                 $scope.ok = function() { $modalInstance.close($scope.args) }
1103                 $scope.cancel = function () { $modalInstance.dismiss() }
1104             }],
1105             resolve : {
1106                 cards : function() {
1107                     // scope here is the controller-level scope
1108                     return $scope.patron.cards;
1109                 }
1110             }
1111         }).result.then(
1112             function(args) {
1113                 angular.forEach(args.cards, function(card) {
1114                     card.ischanged = true; // assume cards need updating, OK?
1115                     if (card._primary == 'on' && 
1116                         card.id != $scope.patron.card.id) {
1117                         $scope.patron.card = card;
1118                     }
1119                 });
1120             }
1121         );
1122     }
1123
1124     $scope.set_addr_type = function(addr, type) {
1125         var addrs = $scope.patron.addresses;
1126         if (addr['_is_'+type]) {
1127             angular.forEach(addrs, function(a) {
1128                 if (a.id != addr.id) a['_is_'+type] = false;
1129             });
1130         } else {
1131             // unchecking mailing/billing means we have to randomly
1132             // select another address to fill that role.  Select the
1133             // first address in the list (that does not match the
1134             // modifed address)
1135             for (var i = 0; i < addrs.length; i++) {
1136                 if (addrs[i].id != addr.id) {
1137                     addrs[i]['_is_' + type] = true;
1138                     break;
1139                 }
1140             }
1141         }
1142     }
1143
1144
1145     // Translate hold notify preferences from the form/scope back into a 
1146     // single user setting value for opac.hold_notify.
1147     function compress_hold_notify() {
1148         var hold_notify = '';
1149         var splitter = '';
1150         if ($scope.hold_notify_phone) {
1151             hold_notify = 'phone';
1152             splitter = ':';
1153         }
1154         if ($scope.hold_notify_email) {
1155             hold_notify = splitter + 'email';
1156             splitter = ':';
1157         }
1158         if ($scope.hold_notify_sms) {
1159             hold_notify = splitter + 'sms';
1160             splitter = ':';
1161         }
1162         $scope.user_settings['opac.hold_notify'] = hold_notify;
1163     }
1164
1165     // dialog for selecting additional permission groups
1166     $scope.secondary_groups_dialog = function() {
1167         $modal.open({
1168             templateUrl: './circ/patron/t_patron_groups_dialog',
1169             controller: 
1170                    ['$scope','$modalInstance','linked_groups','pgt_depth',
1171             function($scope , $modalInstance , linked_groups , pgt_depth) {
1172
1173                 $scope.pgt_depth = pgt_depth;
1174                 $scope.args = {
1175                     linked_groups : linked_groups,
1176                     edit_profiles : patronRegSvc.edit_profiles,
1177                     new_profile   : patronRegSvc.edit_profiles[0]
1178                 };
1179
1180                 // add a new group to the linked groups list
1181                 $scope.link_group = function($event, grp) {
1182                     var found = false; // avoid duplicates
1183                     angular.forEach($scope.args.linked_groups, 
1184                         function(g) {if (g.id() == grp.id()) found = true});
1185                     if (!found) $scope.args.linked_groups.push(grp);
1186                     $event.preventDefault(); // avoid close
1187                 }
1188
1189                 // remove a group from the linked groups list
1190                 $scope.unlink_group = function($event, grp) {
1191                     $scope.args.linked_groups = 
1192                         $scope.args.linked_groups.filter(function(g) {
1193                         return g.id() != grp.id()
1194                     });
1195                     $event.preventDefault(); // avoid close
1196                 }
1197
1198                 $scope.ok = function() { $modalInstance.close($scope.args) }
1199                 $scope.cancel = function () { $modalInstance.dismiss() }
1200             }],
1201             resolve : {
1202                 linked_groups : function() { return $scope.patron.groups },
1203                 pgt_depth : function() { return $scope.pgt_depth }
1204             }
1205         }).result.then(
1206             function(args) {
1207                 var ids = args.linked_groups.map(function(g) {return g.id()});
1208                 console.log('linking permission groups ' + ids);
1209                 return egCore.net.request(
1210                     'open-ils.actor',
1211                     'open-ils.actor.user.set_groups',
1212                     egCore.auth.token(), $scope.patron.id, ids)
1213                 .then(function(resp) {
1214                     if (resp == 1) {
1215                         $scope.patron.groups = args.linked_groups;
1216                     } else {
1217                         // debugging -- should be no events
1218                         alert('linked groups failure ' + egCore.evt.parse(resp));
1219                     }
1220                 });
1221             }
1222         );
1223     }
1224
1225     function extract_hold_notify() {
1226         notify = $scope.user_settings['opac.hold_notify'];
1227         if (!notify) return;
1228         $scope.hold_notify_phone = Boolean(notify.match(/phone/));
1229         $scope.hold_notify_email = Boolean(notify.match(/email/));
1230         $scope.hold_notify_sms = Boolean(notify.match(/sms/));
1231     }
1232
1233     $scope.invalidate_field = function(field) {
1234         patronRegSvc.invalidate_field($scope.patron, field);
1235     }
1236
1237     $scope.dupe_value_changed = function(type, value) {
1238         $scope.dupe_counts[type] = 0;
1239         patronRegSvc.dupe_patron_search($scope.patron, type, value)
1240         .then(function(res) {
1241             $scope.dupe_counts[type] = res.count;
1242             $scope.dupe_search_encoded = 
1243                 encodeURIComponent(js2JSON(res.search));
1244         });
1245     }
1246
1247     $scope.edit_passthru.save = function(save_args) {
1248         if (!save_args) save_args = {};
1249
1250         // remove page unload warning prompt
1251         egUnloadPrompt.clear();
1252
1253         // toss the deleted addresses back into the patron's list of
1254         // addresses so it's included in the update
1255         $scope.patron.addresses = 
1256             $scope.patron.addresses.concat(deleted_addresses);
1257         
1258         compress_hold_notify();
1259
1260         var updated_user;
1261         patronRegSvc.save_user($scope.patron)
1262         .then(function(new_user) { 
1263             if (new_user && new_user.classname) {
1264                 updated_user = new_user;
1265                 return patronRegSvc.save_user_settings(
1266                     new_user, $scope.user_settings); 
1267             } else {
1268                 alert('Patron update failed. \n\n' + js2JSON(new_user));
1269                 return true; // ensure page reloads to reset
1270             }
1271         }).then(function(keep_going) {
1272             // reloading the page means potentially losing some information
1273             // (e.g. last patron search), but is the only way to ensure all
1274             // components are properly updated to reflect the modified patron.
1275             if (save_args.clone) {
1276                 // open a separate tab for registering a new 
1277                 // patron from our cloned data.
1278                 var url = 'https://' 
1279                     + $window.location.hostname 
1280                     + egCore.env.basePath 
1281                     + '/circ/patron/register/clone/' 
1282                     + updated_user.id();
1283                 $window.open(url, '_blank').focus();
1284             } else {
1285                 // reload the current page
1286                 $window.location.href = location.href;
1287             }
1288         });
1289     }
1290 }
1291
1292 // This controller may be loaded from different modules (patron edit vs.
1293 // register new patron), so we have to inject the controller params manually.
1294 PatronRegCtrl.$inject = ['$scope', '$routeParams', '$q', '$modal', 
1295     '$window', 'egCore', 'patronSvc', 'patronRegSvc', 'egUnloadPrompt'];
1296