Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / contact.pm
1 package FS::contact;
2 use base qw( FS::Record );
3
4 use strict;
5 use vars qw( $skip_fuzzyfiles );
6 use Carp;
7 use Scalar::Util qw( blessed );
8 use FS::Record qw( qsearch qsearchs dbh );
9 use FS::contact_phone;
10 use FS::contact_email;
11 use FS::queue;
12 use FS::phone_type; #for cgi_contact_fields
13 use FS::cust_contact;
14 use FS::prospect_contact;
15
16 $skip_fuzzyfiles = 0;
17
18 =head1 NAME
19
20 FS::contact - Object methods for contact records
21
22 =head1 SYNOPSIS
23
24   use FS::contact;
25
26   $record = new FS::contact \%hash;
27   $record = new FS::contact { 'column' => 'value' };
28
29   $error = $record->insert;
30
31   $error = $new_record->replace($old_record);
32
33   $error = $record->delete;
34
35   $error = $record->check;
36
37 =head1 DESCRIPTION
38
39 An FS::contact object represents an specific contact person for a prospect or
40 customer.  FS::contact inherits from FS::Record.  The following fields are
41 currently supported:
42
43 =over 4
44
45 =item contactnum
46
47 primary key
48
49 =item prospectnum
50
51 prospectnum
52
53 =item custnum
54
55 custnum
56
57 =item locationnum
58
59 locationnum
60
61 =item last
62
63 last
64
65 =item first
66
67 first
68
69 =item title
70
71 title
72
73 =item comment
74
75 comment
76
77 =item selfservice_access
78
79 empty or Y
80
81 =item _password
82
83 =item _password_encoding
84
85 empty or bcrypt
86
87 =item disabled
88
89 disabled
90
91
92 =back
93
94 =head1 METHODS
95
96 =over 4
97
98 =item new HASHREF
99
100 Creates a new contact.  To add the contact to the database, see L<"insert">.
101
102 Note that this stores the hash reference, not a distinct copy of the hash it
103 points to.  You can ask the object for a copy with the I<hash> method.
104
105 =cut
106
107 sub table { 'contact'; }
108
109 =item insert
110
111 Adds this record to the database.  If there is an error, returns the error,
112 otherwise returns false.
113
114 =cut
115
116 sub insert {
117   my $self = shift;
118
119   local $SIG{INT} = 'IGNORE';
120   local $SIG{QUIT} = 'IGNORE';
121   local $SIG{TERM} = 'IGNORE';
122   local $SIG{TSTP} = 'IGNORE';
123   local $SIG{PIPE} = 'IGNORE';
124
125   my $oldAutoCommit = $FS::UID::AutoCommit;
126   local $FS::UID::AutoCommit = 0;
127   my $dbh = dbh;
128
129   #save off and blank values that move to cust_contact / prospect_contact now
130   my $prospectnum = $self->prospectnum;
131   $self->prospectnum('');
132   my $custnum = $self->custnum;
133   $self->custnum('');
134
135   my %link_hash = ();
136   for (qw( classnum comment selfservice_access )) {
137     $link_hash{$_} = $self->get($_);
138     $self->$_('');
139   }
140
141   #look for an existing contact with this email address
142   my $existing_contact = '';
143   if ( $self->get('emailaddress') =~ /\S/ ) {
144   
145     my %existing_contact = ();
146
147     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
148  
149       my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
150         or next;
151
152       my $contact = $contact_email->contact;
153       $existing_contact{ $contact->contactnum } = $contact;
154
155     }
156
157     if ( scalar( keys %existing_contact ) > 1 ) {
158       $dbh->rollback if $oldAutoCommit;
159       return 'Multiple email addresses specified '.
160              ' that already belong to separate contacts';
161     } elsif ( scalar( keys %existing_contact ) ) {
162       ($existing_contact) = values %existing_contact;
163     }
164
165   }
166
167   if ( $existing_contact ) {
168
169     $self->$_($existing_contact->$_())
170       for qw( contactnum _password _password_encoding );
171     $self->SUPER::replace($existing_contact);
172
173   } else {
174
175     my $error = $self->SUPER::insert;
176     if ( $error ) {
177       $dbh->rollback if $oldAutoCommit;
178       return $error;
179     }
180
181   }
182
183   my $cust_contact = '';
184   if ( $custnum ) {
185     my %hash = ( 'contactnum' => $self->contactnum,
186                  'custnum'    => $custnum,
187                );
188     $cust_contact =  qsearchs('cust_contact', \%hash )
189                   || new FS::cust_contact { %hash, %link_hash };
190     my $error = $cust_contact->custcontactnum ? $cust_contact->replace
191                                               : $cust_contact->insert;
192     if ( $error ) {
193       $dbh->rollback if $oldAutoCommit;
194       return $error;
195     }
196   }
197
198   if ( $prospectnum ) {
199     my %hash = ( 'contactnum'  => $self->contactnum,
200                  'prospectnum' => $prospectnum,
201                );
202     my $prospect_contact =  qsearchs('prospect_contact', \%hash )
203                          || new FS::prospect_contact { %hash, %link_hash };
204     my $error =
205       $prospect_contact->prospectcontactnum ? $prospect_contact->replace
206                                             : $prospect_contact->insert;
207     if ( $error ) {
208       $dbh->rollback if $oldAutoCommit;
209       return $error;
210     }
211   }
212
213   foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
214                         keys %{ $self->hashref } ) {
215     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
216     my $phonetypenum = $1;
217
218     my %hash = ( 'contactnum'   => $self->contactnum,
219                  'phonetypenum' => $phonetypenum,
220                );
221     my $contact_phone =
222       qsearchs('contact_phone', \%hash)
223         || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) };
224     my $error = $contact_phone->contactphonenum ? $contact_phone->replace
225                                                 : $contact_phone->insert;
226     if ( $error ) {
227       $dbh->rollback if $oldAutoCommit;
228       return $error;
229     }
230   }
231
232   if ( $self->get('emailaddress') =~ /\S/ ) {
233
234     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
235       my %hash = (
236         'contactnum'   => $self->contactnum,
237         'emailaddress' => $email,
238       );
239       unless ( qsearchs('contact_email', \%hash) ) {
240         my $contact_email = new FS::contact_email \%hash;
241         my $error = $contact_email->insert;
242         if ( $error ) {
243           $dbh->rollback if $oldAutoCommit;
244           return $error;
245         }
246       }
247     }
248
249   }
250
251   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
252     #warn "  queueing fuzzyfiles update\n"
253     #  if $DEBUG > 1;
254     my $error = $self->queue_fuzzyfiles_update;
255     if ( $error ) {
256       $dbh->rollback if $oldAutoCommit;
257       return "updating fuzzy search cache: $error";
258     }
259   }
260
261   if (      $link_hash{'selfservice_access'} eq 'R'
262        or ( $link_hash{'selfservice_access'} && $cust_contact )
263      )
264   {
265     my $error = $self->send_reset_email( queue=>1 );
266     if ( $error ) {
267       $dbh->rollback if $oldAutoCommit;
268       return $error;
269     }
270   }
271
272   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
273
274   '';
275
276 }
277
278 =item delete
279
280 Delete this record from the database.
281
282 =cut
283
284 sub delete {
285   my $self = shift;
286
287   local $SIG{HUP} = 'IGNORE';
288   local $SIG{INT} = 'IGNORE';
289   local $SIG{QUIT} = 'IGNORE';
290   local $SIG{TERM} = 'IGNORE';
291   local $SIG{TSTP} = 'IGNORE';
292   local $SIG{PIPE} = 'IGNORE';
293
294   my $oldAutoCommit = $FS::UID::AutoCommit;
295   local $FS::UID::AutoCommit = 0;
296   my $dbh = dbh;
297
298   #got a prospetnum or custnum? delete the prospect_contact or cust_contact link
299
300   if ( $self->prospectnum ) {
301     my $prospect_contact = qsearchs('prospect_contact', {
302                              'contactnum'  => $self->contactnum,
303                              'prospectnum' => $self->prospectnum,
304                            });
305     my $error = $prospect_contact->delete;
306     if ( $error ) {
307       $dbh->rollback if $oldAutoCommit;
308       return $error;
309     }
310   }
311
312   if ( $self->custnum ) {
313     my $cust_contact = qsearchs('cust_contact', {
314                          'contactnum'  => $self->contactnum,
315                          'custnum' => $self->custnum,
316                        });
317     my $error = $cust_contact->delete;
318     if ( $error ) {
319       $dbh->rollback if $oldAutoCommit;
320       return $error;
321     }
322   }
323
324   # then, proceed with deletion only if the contact isn't attached to any other
325   # prospects or customers
326
327   #inefficient, but how many prospects/customers can a single contact be
328   # attached too?  (and is removing them from one a common operation?)
329   if ( $self->prospect_contact || $self->cust_contact ) {
330     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
331     return '';
332   }
333
334   #proceed with deletion
335
336   foreach my $cust_pkg ( $self->cust_pkg ) {
337     $cust_pkg->contactnum('');
338     my $error = $cust_pkg->replace;
339     if ( $error ) {
340       $dbh->rollback if $oldAutoCommit;
341       return $error;
342     }
343   }
344
345   foreach my $object ( $self->contact_phone, $self->contact_email ) {
346     my $error = $object->delete;
347     if ( $error ) {
348       $dbh->rollback if $oldAutoCommit;
349       return $error;
350     }
351   }
352
353   my $error = $self->SUPER::delete;
354   if ( $error ) {
355     $dbh->rollback if $oldAutoCommit;
356     return $error;
357   }
358
359   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
360   '';
361
362 }
363
364 =item replace OLD_RECORD
365
366 Replaces the OLD_RECORD with this one in the database.  If there is an error,
367 returns the error, otherwise returns false.
368
369 =cut
370
371 sub replace {
372   my $self = shift;
373
374   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
375               ? shift
376               : $self->replace_old;
377
378   $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
379
380   local $SIG{INT} = 'IGNORE';
381   local $SIG{QUIT} = 'IGNORE';
382   local $SIG{TERM} = 'IGNORE';
383   local $SIG{TSTP} = 'IGNORE';
384   local $SIG{PIPE} = 'IGNORE';
385
386   my $oldAutoCommit = $FS::UID::AutoCommit;
387   local $FS::UID::AutoCommit = 0;
388   my $dbh = dbh;
389
390   #save off and blank values that move to cust_contact / prospect_contact now
391   my $prospectnum = $self->prospectnum;
392   $self->prospectnum('');
393   my $custnum = $self->custnum;
394   $self->custnum('');
395
396   my %link_hash = ();
397   for (qw( classnum comment selfservice_access )) {
398     $link_hash{$_} = $self->get($_);
399     $self->$_('');
400   }
401
402   my $error = $self->SUPER::replace($old);
403   if ( $error ) {
404     $dbh->rollback if $oldAutoCommit;
405     return $error;
406   }
407
408   my $cust_contact = '';
409   if ( $custnum ) {
410     my %hash = ( 'contactnum' => $self->contactnum,
411                  'custnum'    => $custnum,
412                );
413     my $error;
414     if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
415       $cust_contact->$_($link_hash{$_}) for keys %link_hash;
416       $error = $cust_contact->replace;
417     } else {
418       $cust_contact = new FS::cust_contact { %hash, %link_hash };
419       $error = $cust_contact->insert;
420     }
421     if ( $error ) {
422       $dbh->rollback if $oldAutoCommit;
423       return $error;
424     }
425   }
426
427   if ( $prospectnum ) {
428     my %hash = ( 'contactnum'  => $self->contactnum,
429                  'prospectnum' => $prospectnum,
430                );
431     my $error;
432     if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) {
433       $prospect_contact->$_($link_hash{$_}) for keys %link_hash;
434       $error = $prospect_contact->replace;
435     } else {
436       my $prospect_contact = new FS::prospect_contact { %hash, %link_hash };
437       $error = $prospect_contact->insert;
438     }
439     if ( $error ) {
440       $dbh->rollback if $oldAutoCommit;
441       return $error;
442     }
443   }
444
445   foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
446                         keys %{ $self->hashref } ) {
447     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
448     my $phonetypenum = $1;
449
450     my %cp = ( 'contactnum'   => $self->contactnum,
451                'phonetypenum' => $phonetypenum,
452              );
453     my $contact_phone = qsearchs('contact_phone', \%cp);
454
455     my $pv = $self->get($pf);
456         $pv =~ s/\s//g;
457
458     #if new value is empty, delete old entry
459     if (!$pv) {
460       if ($contact_phone) {
461         $error = $contact_phone->delete;
462         if ( $error ) {
463           $dbh->rollback if $oldAutoCommit;
464           return $error;
465         }
466       }
467       next;
468     }
469
470     $contact_phone ||= new FS::contact_phone \%cp;
471
472     my %cpd = _parse_phonestring( $pv );
473     $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
474
475     my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
476
477     $error = $contact_phone->$method;
478     if ( $error ) {
479       $dbh->rollback if $oldAutoCommit;
480       return $error;
481     }
482   }
483
484   if ( defined($self->hashref->{'emailaddress'}) ) {
485
486     #ineffecient but whatever, how many email addresses can there be?
487
488     foreach my $contact_email ( $self->contact_email ) {
489       my $error = $contact_email->delete;
490       if ( $error ) {
491         $dbh->rollback if $oldAutoCommit;
492         return $error;
493       }
494     }
495
496     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
497  
498       my $contact_email = new FS::contact_email {
499         'contactnum'   => $self->contactnum,
500         'emailaddress' => $email,
501       };
502       $error = $contact_email->insert;
503       if ( $error ) {
504         $dbh->rollback if $oldAutoCommit;
505         return $error;
506       }
507
508     }
509
510   }
511
512   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
513     #warn "  queueing fuzzyfiles update\n"
514     #  if $DEBUG > 1;
515     $error = $self->queue_fuzzyfiles_update;
516     if ( $error ) {
517       $dbh->rollback if $oldAutoCommit;
518       return "updating fuzzy search cache: $error";
519     }
520   }
521
522   if ( $cust_contact and (
523                               (      $cust_contact->selfservice_access eq ''
524                                   && $link_hash{selfservice_access}
525                                   && ! length($self->_password)
526                               )
527                            || $cust_contact->_resend()
528                          )
529     )
530   {
531     my $error = $self->send_reset_email( queue=>1 );
532     if ( $error ) {
533       $dbh->rollback if $oldAutoCommit;
534       return $error;
535     }
536   }
537
538   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
539
540   '';
541
542 }
543
544 =item _parse_phonestring PHONENUMBER_STRING
545
546 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
547 with keys 'countrycode', 'phonenum' and 'extension'
548
549 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
550
551 =cut
552
553 sub _parse_phonestring {
554   my $value = shift;
555
556   my($countrycode, $extension) = ('1', '');
557
558   #countrycode
559   if ( $value =~ s/^\s*\+\s*(\d+)// ) {
560     $countrycode = $1;
561   } else {
562     $value =~ s/^\s*1//;
563   }
564   #extension
565   if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
566      $extension = $2;
567   }
568
569   ( 'countrycode' => $countrycode,
570     'phonenum'    => $value,
571     'extension'   => $extension,
572   );
573 }
574
575 =item queue_fuzzyfiles_update
576
577 Used by insert & replace to update the fuzzy search cache
578
579 =cut
580
581 use FS::cust_main::Search;
582 sub queue_fuzzyfiles_update {
583   my $self = shift;
584
585   local $SIG{HUP} = 'IGNORE';
586   local $SIG{INT} = 'IGNORE';
587   local $SIG{QUIT} = 'IGNORE';
588   local $SIG{TERM} = 'IGNORE';
589   local $SIG{TSTP} = 'IGNORE';
590   local $SIG{PIPE} = 'IGNORE';
591
592   my $oldAutoCommit = $FS::UID::AutoCommit;
593   local $FS::UID::AutoCommit = 0;
594   my $dbh = dbh;
595
596   foreach my $field ( 'first', 'last' ) {
597     my $queue = new FS::queue { 
598       'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
599     };
600     my @args = "contact.$field", $self->get($field);
601     my $error = $queue->insert( @args );
602     if ( $error ) {
603       $dbh->rollback if $oldAutoCommit;
604       return "queueing job (transaction rolled back): $error";
605     }
606   }
607
608   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
609   '';
610
611 }
612
613 =item check
614
615 Checks all fields to make sure this is a valid contact.  If there is
616 an error, returns the error, otherwise returns false.  Called by the insert
617 and replace methods.
618
619 =cut
620
621 sub check {
622   my $self = shift;
623
624   if ( $self->selfservice_access eq 'R' ) {
625     $self->selfservice_access('Y');
626     $self->_resend('Y');
627   }
628
629   my $error = 
630     $self->ut_numbern('contactnum')
631     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
632     || $self->ut_foreign_keyn('custnum',     'cust_main',     'custnum')
633     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
634     || $self->ut_foreign_keyn('classnum',    'contact_class', 'classnum')
635     || $self->ut_namen('last')
636     || $self->ut_namen('first')
637     || $self->ut_textn('title')
638     || $self->ut_textn('comment')
639     || $self->ut_enum('selfservice_access', [ '', 'Y' ])
640     || $self->ut_textn('_password')
641     || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
642     || $self->ut_enum('disabled', [ '', 'Y' ])
643   ;
644   return $error if $error;
645
646   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
647
648   return "One of first name, last name, or title must have a value"
649     if ! grep $self->$_(), qw( first last title);
650
651   $self->SUPER::check;
652 }
653
654 =item line
655
656 Returns a formatted string representing this contact, including name, title and
657 comment.
658
659 =cut
660
661 sub line {
662   my $self = shift;
663   my $data = $self->first. ' '. $self->last;
664   $data .= ', '. $self->title
665     if $self->title;
666   $data .= ' ('. $self->comment. ')'
667     if $self->comment;
668   $data;
669 }
670
671 =item firstlast
672
673 Returns a formatted string representing this contact, with just the name.
674
675 =cut
676
677 sub firstlast {
678   my $self = shift;
679   $self->first . ' ' . $self->last;
680 }
681
682 #=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
683 #
684 #Returns the name of this contact's class for the specified prospect or
685 #customer (see L<FS::prospect_contact>, L<FS::cust_contact> and
686 #L<FS::contact_class>).
687 #
688 #=cut
689 #
690 #sub contact_classname {
691 #  my( $self, $prospect_or_cust ) = @_;
692 #
693 #  my $link = '';
694 #  if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) {
695 #    $link = qsearchs('prospect_contact', {
696 #              'contactnum'  => $self->contactnum,
697 #              'prospectnum' => $prospect_or_cust->prospectnum,
698 #            });
699 #  } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) {
700 #    $link = qsearchs('cust_contact', {
701 #              'contactnum'  => $self->contactnum,
702 #              'custnum'     => $prospect_or_cust->custnum,
703 #            });
704 #  } else {
705 #    croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
706 #  }
707 #
708 #  my $contact_class = $link->contact_class or return '';
709 #  $contact_class->classname;
710 #}
711
712 =item by_selfservice_email EMAILADDRESS
713
714 Alternate search constructor (class method).  Given an email address,
715 returns the contact for that address, or the empty string if no contact
716 has that email address.
717
718 =cut
719
720 sub by_selfservice_email {
721   my($class, $email) = @_;
722
723   my $contact_email = qsearchs({
724     'table'     => 'contact_email',
725     'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
726     'hashref'   => { 'emailaddress' => $email, },
727     'extra_sql' => " AND ( disabled IS NULL OR disabled = '' )",
728   }) or return '';
729
730   $contact_email->contact;
731
732 }
733
734 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
735 # and should maybe be libraried in some way for other password needs
736
737 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
738
739 sub authenticate_password {
740   my($self, $check_password) = @_;
741
742   if ( $self->_password_encoding eq 'bcrypt' ) {
743
744     my( $cost, $salt, $hash ) = split(',', $self->_password);
745
746     my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
747                                                cost    => $cost,
748                                                salt    => de_base64($salt),
749                                              },
750                                              $check_password
751                                            )
752                               );
753
754     $hash eq $check_hash;
755
756   } else { 
757
758     return 0 if $self->_password eq '';
759
760     $self->_password eq $check_password;
761
762   }
763
764 }
765
766 sub change_password {
767   my($self, $new_password) = @_;
768
769   $self->change_password_fields( $new_password );
770
771   $self->replace;
772
773 }
774
775 sub change_password_fields {
776   my($self, $new_password) = @_;
777
778   $self->_password_encoding('bcrypt');
779
780   my $cost = 8;
781
782   my $salt = pack( 'C*', map int(rand(256)), 1..16 );
783
784   my $hash = bcrypt_hash( { key_nul => 1,
785                             cost    => $cost,
786                             salt    => $salt,
787                           },
788                           $new_password,
789                         );
790
791   $self->_password(
792     join(',', $cost, en_base64($salt), en_base64($hash) )
793   );
794
795 }
796
797 # end of false laziness w/FS/FS/Auth/internal.pm
798
799
800 #false laziness w/ClientAPI/MyAccount/reset_passwd
801 use Digest::SHA qw(sha512_hex);
802 use FS::Conf;
803 use FS::ClientAPI_SessionCache;
804 sub send_reset_email {
805   my( $self, %opt ) = @_;
806
807   my @contact_email = $self->contact_email or return '';
808
809   my $reset_session = {
810     'contactnum' => $self->contactnum,
811     'svcnum'     => $opt{'svcnum'},
812   };
813
814   my $timeout = '24 hours'; #?
815
816   my $reset_session_id;
817   do {
818     $reset_session_id = sha512_hex(time(). {}. rand(). $$)
819   } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
820     #just in case
821
822   $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
823
824   #email it
825
826   my $conf = new FS::Conf;
827
828   my $cust_main = '';
829   my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
830   $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
831
832   my $agentnum = $cust_main ? $cust_main->agentnum : '';
833   my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
834   #die "selfservice-password_reset_msgnum unset" unless $msgnum;
835   return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
836   my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
837   my %msg_template = (
838     'to'            => join(',', map $_->emailaddress, @contact_email ),
839     'cust_main'     => $cust_main,
840     'object'        => $self,
841     'substitutions' => { 'session_id' => $reset_session_id }
842   );
843
844   if ( $opt{'queue'} ) { #or should queueing just be the default?
845
846     my $queue = new FS::queue {
847       'job'     => 'FS::Misc::process_send_email',
848       'custnum' => $cust_main ? $cust_main->custnum : '',
849     };
850     $queue->insert( $msg_template->prepare( %msg_template ) );
851
852   } else {
853
854     $msg_template->send( %msg_template );
855
856   }
857
858 }
859
860 use vars qw( $myaccount_cache );
861 sub myaccount_cache {
862   #my $class = shift;
863   $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
864                          'namespace' => 'FS::ClientAPI::MyAccount',
865                        } );
866 }
867
868 =item cgi_contact_fields
869
870 Returns a list reference containing the set of contact fields used in the web
871 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
872 and locationnum, as well as password fields, but including fields for
873 contact_email and contact_phone records.)
874
875 =cut
876
877 sub cgi_contact_fields {
878   #my $class = shift;
879
880   my @contact_fields = qw(
881     classnum first last title comment emailaddress selfservice_access
882   );
883
884   push @contact_fields, 'phonetypenum'. $_->phonetypenum
885     foreach qsearch({table=>'phone_type', order_by=>'weight'});
886
887   \@contact_fields;
888
889 }
890
891 use FS::upgrade_journal;
892 sub _upgrade_data { #class method
893   my ($class, %opts) = @_;
894
895   unless ( FS::upgrade_journal->is_done('contact__DUPEMAIL') ) {
896
897     foreach my $contact (qsearch('contact', {})) {
898       my $error = $contact->replace;
899       die $error if $error;
900     }
901
902     FS::upgrade_journal->set_done('contact__DUPEMAIL');
903   }
904
905 }
906
907 =back
908
909 =head1 BUGS
910
911 =head1 SEE ALSO
912
913 L<FS::Record>, schema.html from the base documentation.
914
915 =cut
916
917 1;
918