contacts can be shared among customers / "duplicate contact emails", RT#27943
[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     #if new value is empty, delete old entry
456     if (!$self->get($pf)) {
457       if ($contact_phone) {
458         $error = $contact_phone->delete;
459         if ( $error ) {
460           $dbh->rollback if $oldAutoCommit;
461           return $error;
462         }
463       }
464       next;
465     }
466
467     my %cpd = _parse_phonestring( $self->get($pf) );
468     $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
469
470     my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
471
472     $error = $contact_phone->$method;
473     if ( $error ) {
474       $dbh->rollback if $oldAutoCommit;
475       return $error;
476     }
477   }
478
479   if ( defined($self->hashref->{'emailaddress'}) ) {
480
481     #ineffecient but whatever, how many email addresses can there be?
482
483     foreach my $contact_email ( $self->contact_email ) {
484       my $error = $contact_email->delete;
485       if ( $error ) {
486         $dbh->rollback if $oldAutoCommit;
487         return $error;
488       }
489     }
490
491     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
492  
493       my $contact_email = new FS::contact_email {
494         'contactnum'   => $self->contactnum,
495         'emailaddress' => $email,
496       };
497       $error = $contact_email->insert;
498       if ( $error ) {
499         $dbh->rollback if $oldAutoCommit;
500         return $error;
501       }
502
503     }
504
505   }
506
507   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
508     #warn "  queueing fuzzyfiles update\n"
509     #  if $DEBUG > 1;
510     $error = $self->queue_fuzzyfiles_update;
511     if ( $error ) {
512       $dbh->rollback if $oldAutoCommit;
513       return "updating fuzzy search cache: $error";
514     }
515   }
516
517   if ( $cust_contact and (
518                               (      $cust_contact->selfservice_access eq ''
519                                   && $link_hash{selfservice_access}
520                                   && ! length($self->_password)
521                               )
522                            || $cust_contact->_resend()
523                          )
524     )
525   {
526     my $error = $self->send_reset_email( queue=>1 );
527     if ( $error ) {
528       $dbh->rollback if $oldAutoCommit;
529       return $error;
530     }
531   }
532
533   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
534
535   '';
536
537 }
538
539 =item _parse_phonestring PHONENUMBER_STRING
540
541 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
542 with keys 'countrycode', 'phonenum' and 'extension'
543
544 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
545
546 =cut
547
548 sub _parse_phonestring {
549   my $value = shift;
550
551   my($countrycode, $extension) = ('1', '');
552
553   #countrycode
554   if ( $value =~ s/^\s*\+\s*(\d+)// ) {
555     $countrycode = $1;
556   } else {
557     $value =~ s/^\s*1//;
558   }
559   #extension
560   if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
561      $extension = $2;
562   }
563
564   ( 'countrycode' => $countrycode,
565     'phonenum'    => $value,
566     'extension'   => $extension,
567   );
568 }
569
570 =item queue_fuzzyfiles_update
571
572 Used by insert & replace to update the fuzzy search cache
573
574 =cut
575
576 use FS::cust_main::Search;
577 sub queue_fuzzyfiles_update {
578   my $self = shift;
579
580   local $SIG{HUP} = 'IGNORE';
581   local $SIG{INT} = 'IGNORE';
582   local $SIG{QUIT} = 'IGNORE';
583   local $SIG{TERM} = 'IGNORE';
584   local $SIG{TSTP} = 'IGNORE';
585   local $SIG{PIPE} = 'IGNORE';
586
587   my $oldAutoCommit = $FS::UID::AutoCommit;
588   local $FS::UID::AutoCommit = 0;
589   my $dbh = dbh;
590
591   foreach my $field ( 'first', 'last' ) {
592     my $queue = new FS::queue { 
593       'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
594     };
595     my @args = "contact.$field", $self->get($field);
596     my $error = $queue->insert( @args );
597     if ( $error ) {
598       $dbh->rollback if $oldAutoCommit;
599       return "queueing job (transaction rolled back): $error";
600     }
601   }
602
603   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
604   '';
605
606 }
607
608 =item check
609
610 Checks all fields to make sure this is a valid contact.  If there is
611 an error, returns the error, otherwise returns false.  Called by the insert
612 and replace methods.
613
614 =cut
615
616 sub check {
617   my $self = shift;
618
619   if ( $self->selfservice_access eq 'R' ) {
620     $self->selfservice_access('Y');
621     $self->_resend('Y');
622   }
623
624   my $error = 
625     $self->ut_numbern('contactnum')
626     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
627     || $self->ut_foreign_keyn('custnum',     'cust_main',     'custnum')
628     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
629     || $self->ut_foreign_keyn('classnum',    'contact_class', 'classnum')
630     || $self->ut_namen('last')
631     || $self->ut_namen('first')
632     || $self->ut_textn('title')
633     || $self->ut_textn('comment')
634     || $self->ut_enum('selfservice_access', [ '', 'Y' ])
635     || $self->ut_textn('_password')
636     || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
637     || $self->ut_enum('disabled', [ '', 'Y' ])
638   ;
639   return $error if $error;
640
641   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
642
643   return "One of first name, last name, or title must have a value"
644     if ! grep $self->$_(), qw( first last title);
645
646   $self->SUPER::check;
647 }
648
649 =item line
650
651 Returns a formatted string representing this contact, including name, title and
652 comment.
653
654 =cut
655
656 sub line {
657   my $self = shift;
658   my $data = $self->first. ' '. $self->last;
659   $data .= ', '. $self->title
660     if $self->title;
661   $data .= ' ('. $self->comment. ')'
662     if $self->comment;
663   $data;
664 }
665
666 =item firstlast
667
668 Returns a formatted string representing this contact, with just the name.
669
670 =cut
671
672 sub firstlast {
673   my $self = shift;
674   $self->first . ' ' . $self->last;
675 }
676
677 #=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
678 #
679 #Returns the name of this contact's class for the specified prospect or
680 #customer (see L<FS::prospect_contact>, L<FS::cust_contact> and
681 #L<FS::contact_class>).
682 #
683 #=cut
684 #
685 #sub contact_classname {
686 #  my( $self, $prospect_or_cust ) = @_;
687 #
688 #  my $link = '';
689 #  if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) {
690 #    $link = qsearchs('prospect_contact', {
691 #              'contactnum'  => $self->contactnum,
692 #              'prospectnum' => $prospect_or_cust->prospectnum,
693 #            });
694 #  } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) {
695 #    $link = qsearchs('cust_contact', {
696 #              'contactnum'  => $self->contactnum,
697 #              'custnum'     => $prospect_or_cust->custnum,
698 #            });
699 #  } else {
700 #    croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
701 #  }
702 #
703 #  my $contact_class = $link->contact_class or return '';
704 #  $contact_class->classname;
705 #}
706
707 =item by_selfservice_email EMAILADDRESS
708
709 Alternate search constructor (class method).  Given an email address,
710 returns the contact for that address, or the empty string if no contact
711 has that email address.
712
713 =cut
714
715 sub by_selfservice_email {
716   my($class, $email) = @_;
717
718   my $contact_email = qsearchs({
719     'table'     => 'contact_email',
720     'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
721     'hashref'   => { 'emailaddress' => $email, },
722     'extra_sql' => " AND ( disabled IS NULL OR disabled = '' )",
723   }) or return '';
724
725   $contact_email->contact;
726
727 }
728
729 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
730 # and should maybe be libraried in some way for other password needs
731
732 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
733
734 sub authenticate_password {
735   my($self, $check_password) = @_;
736
737   if ( $self->_password_encoding eq 'bcrypt' ) {
738
739     my( $cost, $salt, $hash ) = split(',', $self->_password);
740
741     my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
742                                                cost    => $cost,
743                                                salt    => de_base64($salt),
744                                              },
745                                              $check_password
746                                            )
747                               );
748
749     $hash eq $check_hash;
750
751   } else { 
752
753     return 0 if $self->_password eq '';
754
755     $self->_password eq $check_password;
756
757   }
758
759 }
760
761 sub change_password {
762   my($self, $new_password) = @_;
763
764   $self->change_password_fields( $new_password );
765
766   $self->replace;
767
768 }
769
770 sub change_password_fields {
771   my($self, $new_password) = @_;
772
773   $self->_password_encoding('bcrypt');
774
775   my $cost = 8;
776
777   my $salt = pack( 'C*', map int(rand(256)), 1..16 );
778
779   my $hash = bcrypt_hash( { key_nul => 1,
780                             cost    => $cost,
781                             salt    => $salt,
782                           },
783                           $new_password,
784                         );
785
786   $self->_password(
787     join(',', $cost, en_base64($salt), en_base64($hash) )
788   );
789
790 }
791
792 # end of false laziness w/FS/FS/Auth/internal.pm
793
794
795 #false laziness w/ClientAPI/MyAccount/reset_passwd
796 use Digest::SHA qw(sha512_hex);
797 use FS::Conf;
798 use FS::ClientAPI_SessionCache;
799 sub send_reset_email {
800   my( $self, %opt ) = @_;
801
802   my @contact_email = $self->contact_email or return '';
803
804   my $reset_session = {
805     'contactnum' => $self->contactnum,
806     'svcnum'     => $opt{'svcnum'},
807   };
808
809   my $timeout = '24 hours'; #?
810
811   my $reset_session_id;
812   do {
813     $reset_session_id = sha512_hex(time(). {}. rand(). $$)
814   } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
815     #just in case
816
817   $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
818
819   #email it
820
821   my $conf = new FS::Conf;
822
823   my $cust_main = '';
824   my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
825   $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
826
827   my $agentnum = $cust_main ? $cust_main->agentnum : '';
828   my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
829   #die "selfservice-password_reset_msgnum unset" unless $msgnum;
830   return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
831   my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
832   my %msg_template = (
833     'to'            => join(',', map $_->emailaddress, @contact_email ),
834     'cust_main'     => $cust_main,
835     'object'        => $self,
836     'substitutions' => { 'session_id' => $reset_session_id }
837   );
838
839   if ( $opt{'queue'} ) { #or should queueing just be the default?
840
841     my $queue = new FS::queue {
842       'job'     => 'FS::Misc::process_send_email',
843       'custnum' => $cust_main ? $cust_main->custnum : '',
844     };
845     $queue->insert( $msg_template->prepare( %msg_template ) );
846
847   } else {
848
849     $msg_template->send( %msg_template );
850
851   }
852
853 }
854
855 use vars qw( $myaccount_cache );
856 sub myaccount_cache {
857   #my $class = shift;
858   $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
859                          'namespace' => 'FS::ClientAPI::MyAccount',
860                        } );
861 }
862
863 =item cgi_contact_fields
864
865 Returns a list reference containing the set of contact fields used in the web
866 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
867 and locationnum, as well as password fields, but including fields for
868 contact_email and contact_phone records.)
869
870 =cut
871
872 sub cgi_contact_fields {
873   #my $class = shift;
874
875   my @contact_fields = qw(
876     classnum first last title comment emailaddress selfservice_access
877   );
878
879   push @contact_fields, 'phonetypenum'. $_->phonetypenum
880     foreach qsearch({table=>'phone_type', order_by=>'weight'});
881
882   \@contact_fields;
883
884 }
885
886 use FS::upgrade_journal;
887 sub _upgrade_data { #class method
888   my ($class, %opts) = @_;
889
890   unless ( FS::upgrade_journal->is_done('contact__DUPEMAIL') ) {
891
892     foreach my $contact (qsearch('contact', {})) {
893       my $error = $contact->replace;
894       die $error if $error;
895     }
896
897     FS::upgrade_journal->set_done('contact__DUPEMAIL');
898   }
899
900 }
901
902 =back
903
904 =head1 BUGS
905
906 =head1 SEE ALSO
907
908 L<FS::Record>, schema.html from the base documentation.
909
910 =cut
911
912 1;
913