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