2 use base qw( FS::Record );
5 use vars qw( $skip_fuzzyfiles );
7 use Scalar::Util qw( blessed );
8 use FS::Record qw( qsearch qsearchs dbh );
10 use FS::contact_email;
12 use FS::phone_type; #for cgi_contact_fields
14 use FS::prospect_contact;
20 FS::contact - Object methods for contact records
26 $record = new FS::contact \%hash;
27 $record = new FS::contact { 'column' => 'value' };
29 $error = $record->insert;
31 $error = $new_record->replace($old_record);
33 $error = $record->delete;
35 $error = $record->check;
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
77 =item selfservice_access
83 =item _password_encoding
100 Creates a new contact. To add the contact to the database, see L<"insert">.
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.
107 sub table { 'contact'; }
111 Adds this record to the database. If there is an error, returns the error,
112 otherwise returns false.
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';
125 my $oldAutoCommit = $FS::UID::AutoCommit;
126 local $FS::UID::AutoCommit = 0;
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;
136 for (qw( classnum comment selfservice_access )) {
137 $link_hash{$_} = $self->get($_);
141 #look for an existing contact with this email address
142 my $existing_contact = '';
143 if ( $self->get('emailaddress') =~ /\S/ ) {
145 my %existing_contact = ();
147 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
149 my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
152 my $contact = $contact_email->contact;
153 $existing_contact{ $contact->contactnum } = $contact;
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;
167 if ( $existing_contact ) {
169 $self->$_($existing_contact->$_())
170 for qw( contactnum _password _password_encoding );
171 $self->SUPER::replace($existing_contact);
175 my $error = $self->SUPER::insert;
177 $dbh->rollback if $oldAutoCommit;
183 my $cust_contact = '';
185 my %hash = ( 'contactnum' => $self->contactnum,
186 'custnum' => $custnum,
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;
193 $dbh->rollback if $oldAutoCommit;
198 if ( $prospectnum ) {
199 my %hash = ( 'contactnum' => $self->contactnum,
200 'prospectnum' => $prospectnum,
202 my $prospect_contact = qsearchs('prospect_contact', \%hash )
203 || new FS::prospect_contact { %hash, %link_hash };
205 $prospect_contact->prospectcontactnum ? $prospect_contact->replace
206 : $prospect_contact->insert;
208 $dbh->rollback if $oldAutoCommit;
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;
218 my %hash = ( 'contactnum' => $self->contactnum,
219 'phonetypenum' => $phonetypenum,
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;
227 $dbh->rollback if $oldAutoCommit;
232 if ( $self->get('emailaddress') =~ /\S/ ) {
234 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
236 'contactnum' => $self->contactnum,
237 'emailaddress' => $email,
239 unless ( qsearchs('contact_email', \%hash) ) {
240 my $contact_email = new FS::contact_email \%hash;
241 my $error = $contact_email->insert;
243 $dbh->rollback if $oldAutoCommit;
251 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
252 #warn " queueing fuzzyfiles update\n"
254 my $error = $self->queue_fuzzyfiles_update;
256 $dbh->rollback if $oldAutoCommit;
257 return "updating fuzzy search cache: $error";
261 if ( $link_hash{'selfservice_access'} eq 'R'
262 or ( $link_hash{'selfservice_access'} && $cust_contact )
265 my $error = $self->send_reset_email( queue=>1 );
267 $dbh->rollback if $oldAutoCommit;
272 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
280 Delete this record from the database.
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';
294 my $oldAutoCommit = $FS::UID::AutoCommit;
295 local $FS::UID::AutoCommit = 0;
298 #got a prospetnum or custnum? delete the prospect_contact or cust_contact link
300 if ( $self->prospectnum ) {
301 my $prospect_contact = qsearchs('prospect_contact', {
302 'contactnum' => $self->contactnum,
303 'prospectnum' => $self->prospectnum,
305 my $error = $prospect_contact->delete;
307 $dbh->rollback if $oldAutoCommit;
312 if ( $self->custnum ) {
313 my $cust_contact = qsearchs('cust_contact', {
314 'contactnum' => $self->contactnum,
315 'custnum' => $self->custnum,
317 my $error = $cust_contact->delete;
319 $dbh->rollback if $oldAutoCommit;
324 # then, proceed with deletion only if the contact isn't attached to any other
325 # prospects or customers
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;
334 #proceed with deletion
336 foreach my $cust_pkg ( $self->cust_pkg ) {
337 $cust_pkg->contactnum('');
338 my $error = $cust_pkg->replace;
340 $dbh->rollback if $oldAutoCommit;
345 foreach my $object ( $self->contact_phone, $self->contact_email ) {
346 my $error = $object->delete;
348 $dbh->rollback if $oldAutoCommit;
353 my $error = $self->SUPER::delete;
355 $dbh->rollback if $oldAutoCommit;
359 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
364 =item replace OLD_RECORD
366 Replaces the OLD_RECORD with this one in the database. If there is an error,
367 returns the error, otherwise returns false.
374 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
376 : $self->replace_old;
378 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
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';
386 my $oldAutoCommit = $FS::UID::AutoCommit;
387 local $FS::UID::AutoCommit = 0;
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;
397 for (qw( classnum comment selfservice_access )) {
398 $link_hash{$_} = $self->get($_);
402 my $error = $self->SUPER::replace($old);
404 $dbh->rollback if $oldAutoCommit;
408 my $cust_contact = '';
410 my %hash = ( 'contactnum' => $self->contactnum,
411 'custnum' => $custnum,
414 if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
415 $cust_contact->$_($link_hash{$_}) for keys %link_hash;
416 $error = $cust_contact->replace;
418 $cust_contact = new FS::cust_contact { %hash, %link_hash };
419 $error = $cust_contact->insert;
422 $dbh->rollback if $oldAutoCommit;
427 if ( $prospectnum ) {
428 my %hash = ( 'contactnum' => $self->contactnum,
429 'prospectnum' => $prospectnum,
432 if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) {
433 $prospect_contact->$_($link_hash{$_}) for keys %link_hash;
434 $error = $prospect_contact->replace;
436 my $prospect_contact = new FS::prospect_contact { %hash, %link_hash };
437 $error = $prospect_contact->insert;
440 $dbh->rollback if $oldAutoCommit;
445 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
446 keys %{ $self->hashref } ) {
447 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
448 my $phonetypenum = $1;
450 my %cp = ( 'contactnum' => $self->contactnum,
451 'phonetypenum' => $phonetypenum,
453 my $contact_phone = qsearchs('contact_phone', \%cp);
455 #if new value is empty, delete old entry
456 if (!$self->get($pf)) {
457 if ($contact_phone) {
458 $error = $contact_phone->delete;
460 $dbh->rollback if $oldAutoCommit;
467 $contact_phone ||= new FS::contact_phone \%cp;
469 my %cpd = _parse_phonestring( $self->get($pf) );
470 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
472 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
474 $error = $contact_phone->$method;
476 $dbh->rollback if $oldAutoCommit;
481 if ( defined($self->hashref->{'emailaddress'}) ) {
483 #ineffecient but whatever, how many email addresses can there be?
485 foreach my $contact_email ( $self->contact_email ) {
486 my $error = $contact_email->delete;
488 $dbh->rollback if $oldAutoCommit;
493 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
495 my $contact_email = new FS::contact_email {
496 'contactnum' => $self->contactnum,
497 'emailaddress' => $email,
499 $error = $contact_email->insert;
501 $dbh->rollback if $oldAutoCommit;
509 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
510 #warn " queueing fuzzyfiles update\n"
512 $error = $self->queue_fuzzyfiles_update;
514 $dbh->rollback if $oldAutoCommit;
515 return "updating fuzzy search cache: $error";
519 if ( $cust_contact and (
520 ( $cust_contact->selfservice_access eq ''
521 && $link_hash{selfservice_access}
522 && ! length($self->_password)
524 || $cust_contact->_resend()
528 my $error = $self->send_reset_email( queue=>1 );
530 $dbh->rollback if $oldAutoCommit;
535 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
541 =item _parse_phonestring PHONENUMBER_STRING
543 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
544 with keys 'countrycode', 'phonenum' and 'extension'
546 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
550 sub _parse_phonestring {
553 my($countrycode, $extension) = ('1', '');
556 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
562 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
566 ( 'countrycode' => $countrycode,
567 'phonenum' => $value,
568 'extension' => $extension,
572 =item queue_fuzzyfiles_update
574 Used by insert & replace to update the fuzzy search cache
578 use FS::cust_main::Search;
579 sub queue_fuzzyfiles_update {
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';
589 my $oldAutoCommit = $FS::UID::AutoCommit;
590 local $FS::UID::AutoCommit = 0;
593 foreach my $field ( 'first', 'last' ) {
594 my $queue = new FS::queue {
595 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
597 my @args = "contact.$field", $self->get($field);
598 my $error = $queue->insert( @args );
600 $dbh->rollback if $oldAutoCommit;
601 return "queueing job (transaction rolled back): $error";
605 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
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
621 if ( $self->selfservice_access eq 'R' ) {
622 $self->selfservice_access('Y');
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' ])
641 return $error if $error;
643 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
645 return "One of first name, last name, or title must have a value"
646 if ! grep $self->$_(), qw( first last title);
653 Returns a formatted string representing this contact, including name, title and
660 my $data = $self->first. ' '. $self->last;
661 $data .= ', '. $self->title
663 $data .= ' ('. $self->comment. ')'
670 Returns a formatted string representing this contact, with just the name.
676 $self->first . ' ' . $self->last;
679 #=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
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>).
687 #sub contact_classname {
688 # my( $self, $prospect_or_cust ) = @_;
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,
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,
702 # croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
705 # my $contact_class = $link->contact_class or return '';
706 # $contact_class->classname;
709 =item by_selfservice_email EMAILADDRESS
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.
717 sub by_selfservice_email {
718 my($class, $email) = @_;
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 = '' )",
727 $contact_email->contact;
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
734 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
736 sub authenticate_password {
737 my($self, $check_password) = @_;
739 if ( $self->_password_encoding eq 'bcrypt' ) {
741 my( $cost, $salt, $hash ) = split(',', $self->_password);
743 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
745 salt => de_base64($salt),
751 $hash eq $check_hash;
755 return 0 if $self->_password eq '';
757 $self->_password eq $check_password;
763 sub change_password {
764 my($self, $new_password) = @_;
766 $self->change_password_fields( $new_password );
772 sub change_password_fields {
773 my($self, $new_password) = @_;
775 $self->_password_encoding('bcrypt');
779 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
781 my $hash = bcrypt_hash( { key_nul => 1,
789 join(',', $cost, en_base64($salt), en_base64($hash) )
794 # end of false laziness w/FS/FS/Auth/internal.pm
797 #false laziness w/ClientAPI/MyAccount/reset_passwd
798 use Digest::SHA qw(sha512_hex);
800 use FS::ClientAPI_SessionCache;
801 sub send_reset_email {
802 my( $self, %opt ) = @_;
804 my @contact_email = $self->contact_email or return '';
806 my $reset_session = {
807 'contactnum' => $self->contactnum,
808 'svcnum' => $opt{'svcnum'},
811 my $timeout = '24 hours'; #?
813 my $reset_session_id;
815 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
816 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
819 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
823 my $conf = new FS::Conf;
826 my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
827 $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
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 } );
835 'to' => join(',', map $_->emailaddress, @contact_email ),
836 'cust_main' => $cust_main,
838 'substitutions' => { 'session_id' => $reset_session_id }
841 if ( $opt{'queue'} ) { #or should queueing just be the default?
843 my $queue = new FS::queue {
844 'job' => 'FS::Misc::process_send_email',
845 'custnum' => $cust_main ? $cust_main->custnum : '',
847 $queue->insert( $msg_template->prepare( %msg_template ) );
851 $msg_template->send( %msg_template );
857 use vars qw( $myaccount_cache );
858 sub myaccount_cache {
860 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
861 'namespace' => 'FS::ClientAPI::MyAccount',
865 =item cgi_contact_fields
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.)
874 sub cgi_contact_fields {
877 my @contact_fields = qw(
878 classnum first last title comment emailaddress selfservice_access
881 push @contact_fields, 'phonetypenum'. $_->phonetypenum
882 foreach qsearch({table=>'phone_type', order_by=>'weight'});
888 use FS::upgrade_journal;
889 sub _upgrade_data { #class method
890 my ($class, %opts) = @_;
892 unless ( FS::upgrade_journal->is_done('contact__DUPEMAIL') ) {
894 foreach my $contact (qsearch('contact', {})) {
895 my $error = $contact->replace;
896 die $error if $error;
899 FS::upgrade_journal->set_done('contact__DUPEMAIL');
910 L<FS::Record>, schema.html from the base documentation.