2 use base qw( FS::Password_Mixin
6 use vars qw( $skip_fuzzyfiles );
8 use Scalar::Util qw( blessed );
9 use FS::Record qw( qsearch qsearchs dbh );
11 use FS::contact_phone;
12 use FS::contact_email;
14 use FS::phone_type; #for cgi_contact_fields
16 use FS::prospect_contact;
22 FS::contact - Object methods for contact records
28 $record = new FS::contact \%hash;
29 $record = new FS::contact { 'column' => 'value' };
31 $error = $record->insert;
33 $error = $new_record->replace($old_record);
35 $error = $record->delete;
37 $error = $record->check;
41 An FS::contact object represents an specific contact person for a prospect or
42 customer. FS::contact inherits from FS::Record. The following fields are
79 =item selfservice_access
85 =item _password_encoding
101 Creates a new contact. To add the contact to the database, see L<"insert">.
103 Note that this stores the hash reference, not a distinct copy of the hash it
104 points to. You can ask the object for a copy with the I<hash> method.
108 sub table { 'contact'; }
112 Adds this record to the database. If there is an error, returns the error,
113 otherwise returns false.
115 If the object has an C<emailaddress> field, L<FS::contact_email> records will
116 be created for each (comma-separated) email address in that field. If any of
117 these coincide with an existing email address, this contact will be merged with
118 the contact with that address.
120 Then, if the object has any fields named C<phonetypenumN> an
121 L<FS::contact_phone> record will be created for each of them. Those fields
122 should contain phone numbers of the appropriate types (where N is the key of
123 an L<FS::phone_type> record identifying the type of number: daytime, night,
126 After inserting the record, if the object has a 'custnum' or 'prospectnum'
127 field, an L<FS::cust_contact> or L<FS::prospect_contact> record will be
128 created to link the contact to the customer. The following fields will also
129 be included in that record, if they are set on the object:
140 local $SIG{INT} = 'IGNORE';
141 local $SIG{QUIT} = 'IGNORE';
142 local $SIG{TERM} = 'IGNORE';
143 local $SIG{TSTP} = 'IGNORE';
144 local $SIG{PIPE} = 'IGNORE';
146 my $oldAutoCommit = $FS::UID::AutoCommit;
147 local $FS::UID::AutoCommit = 0;
150 #save off and blank values that move to cust_contact / prospect_contact now
151 my $prospectnum = $self->prospectnum;
152 $self->prospectnum('');
153 my $custnum = $self->custnum;
157 for (qw( classnum comment selfservice_access invoice_dest )) {
158 $link_hash{$_} = $self->get($_);
162 #look for an existing contact with this email address
163 my $existing_contact = '';
164 if ( $self->get('emailaddress') =~ /\S/ ) {
166 my %existing_contact = ();
168 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
170 my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
173 my $contact = $contact_email->contact;
174 $existing_contact{ $contact->contactnum } = $contact;
178 if ( scalar( keys %existing_contact ) > 1 ) {
179 $dbh->rollback if $oldAutoCommit;
180 return 'Multiple email addresses specified '.
181 ' that already belong to separate contacts';
182 } elsif ( scalar( keys %existing_contact ) ) {
183 ($existing_contact) = values %existing_contact;
189 if ( $existing_contact ) {
191 $self->$_($existing_contact->$_())
192 for qw( contactnum _password _password_encoding );
193 $error = $self->SUPER::replace($existing_contact);
197 $error = $self->SUPER::insert;
201 $error ||= $self->insert_password_history;
204 $dbh->rollback if $oldAutoCommit;
208 my $cust_contact = '';
210 my %hash = ( 'contactnum' => $self->contactnum,
211 'custnum' => $custnum,
213 $cust_contact = qsearchs('cust_contact', \%hash )
214 || new FS::cust_contact { %hash, %link_hash };
215 my $error = $cust_contact->custcontactnum ? $cust_contact->replace
216 : $cust_contact->insert;
218 $dbh->rollback if $oldAutoCommit;
223 if ( $prospectnum ) {
224 my %hash = ( 'contactnum' => $self->contactnum,
225 'prospectnum' => $prospectnum,
227 my $prospect_contact = qsearchs('prospect_contact', \%hash )
228 || new FS::prospect_contact { %hash, %link_hash };
230 $prospect_contact->prospectcontactnum ? $prospect_contact->replace
231 : $prospect_contact->insert;
233 $dbh->rollback if $oldAutoCommit;
238 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
239 keys %{ $self->hashref } ) {
240 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
241 my $phonetypenum = $1;
243 my %hash = ( 'contactnum' => $self->contactnum,
244 'phonetypenum' => $phonetypenum,
247 qsearchs('contact_phone', \%hash)
248 || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) };
249 my $error = $contact_phone->contactphonenum ? $contact_phone->replace
250 : $contact_phone->insert;
252 $dbh->rollback if $oldAutoCommit;
257 if ( $self->get('emailaddress') =~ /\S/ ) {
259 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
261 'contactnum' => $self->contactnum,
262 'emailaddress' => $email,
264 unless ( qsearchs('contact_email', \%hash) ) {
265 my $contact_email = new FS::contact_email \%hash;
266 my $error = $contact_email->insert;
268 $dbh->rollback if $oldAutoCommit;
276 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
277 #warn " queueing fuzzyfiles update\n"
279 my $error = $self->queue_fuzzyfiles_update;
281 $dbh->rollback if $oldAutoCommit;
282 return "updating fuzzy search cache: $error";
286 if ( $link_hash{'selfservice_access'} eq 'R'
287 or ( $link_hash{'selfservice_access'}
289 && ! length($self->_password)
293 my $error = $self->send_reset_email( queue=>1 );
295 $dbh->rollback if $oldAutoCommit;
300 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
308 Delete this record from the database.
315 local $SIG{HUP} = 'IGNORE';
316 local $SIG{INT} = 'IGNORE';
317 local $SIG{QUIT} = 'IGNORE';
318 local $SIG{TERM} = 'IGNORE';
319 local $SIG{TSTP} = 'IGNORE';
320 local $SIG{PIPE} = 'IGNORE';
322 my $oldAutoCommit = $FS::UID::AutoCommit;
323 local $FS::UID::AutoCommit = 0;
326 #got a prospetnum or custnum? delete the prospect_contact or cust_contact link
328 if ( $self->prospectnum ) {
329 my $prospect_contact = qsearchs('prospect_contact', {
330 'contactnum' => $self->contactnum,
331 'prospectnum' => $self->prospectnum,
333 my $error = $prospect_contact->delete;
335 $dbh->rollback if $oldAutoCommit;
340 if ( $self->custnum ) {
341 my $cust_contact = qsearchs('cust_contact', {
342 'contactnum' => $self->contactnum,
343 'custnum' => $self->custnum,
345 my $error = $cust_contact->delete;
347 $dbh->rollback if $oldAutoCommit;
352 # then, proceed with deletion only if the contact isn't attached to any other
353 # prospects or customers
355 #inefficient, but how many prospects/customers can a single contact be
356 # attached too? (and is removing them from one a common operation?)
357 if ( $self->prospect_contact || $self->cust_contact ) {
358 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
362 #proceed with deletion
364 foreach my $cust_pkg ( $self->cust_pkg ) {
365 $cust_pkg->contactnum('');
366 my $error = $cust_pkg->replace;
368 $dbh->rollback if $oldAutoCommit;
373 foreach my $object ( $self->contact_phone, $self->contact_email ) {
374 my $error = $object->delete;
376 $dbh->rollback if $oldAutoCommit;
381 my $error = $self->delete_password_history
382 || $self->SUPER::delete;
384 $dbh->rollback if $oldAutoCommit;
388 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
393 =item replace OLD_RECORD
395 Replaces the OLD_RECORD with this one in the database. If there is an error,
396 returns the error, otherwise returns false.
403 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
405 : $self->replace_old;
407 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
409 local $SIG{INT} = 'IGNORE';
410 local $SIG{QUIT} = 'IGNORE';
411 local $SIG{TERM} = 'IGNORE';
412 local $SIG{TSTP} = 'IGNORE';
413 local $SIG{PIPE} = 'IGNORE';
415 my $oldAutoCommit = $FS::UID::AutoCommit;
416 local $FS::UID::AutoCommit = 0;
419 #save off and blank values that move to cust_contact / prospect_contact now
420 my $prospectnum = $self->prospectnum;
421 $self->prospectnum('');
422 my $custnum = $self->custnum;
426 for (qw( classnum comment selfservice_access invoice_dest )) {
427 $link_hash{$_} = $self->get($_);
431 my $error = $self->SUPER::replace($old);
432 if ( $old->_password ne $self->_password ) {
433 $error ||= $self->insert_password_history;
436 $dbh->rollback if $oldAutoCommit;
440 my $cust_contact = '';
442 my %hash = ( 'contactnum' => $self->contactnum,
443 'custnum' => $custnum,
446 if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
447 $cust_contact->$_($link_hash{$_}) for keys %link_hash;
448 $error = $cust_contact->replace;
450 $cust_contact = new FS::cust_contact { %hash, %link_hash };
451 $error = $cust_contact->insert;
454 $dbh->rollback if $oldAutoCommit;
459 if ( $prospectnum ) {
460 my %hash = ( 'contactnum' => $self->contactnum,
461 'prospectnum' => $prospectnum,
464 if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) {
465 $prospect_contact->$_($link_hash{$_}) for keys %link_hash;
466 $error = $prospect_contact->replace;
468 my $prospect_contact = new FS::prospect_contact { %hash, %link_hash };
469 $error = $prospect_contact->insert;
472 $dbh->rollback if $oldAutoCommit;
477 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
478 keys %{ $self->hashref } ) {
479 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
480 my $phonetypenum = $1;
482 my %cp = ( 'contactnum' => $self->contactnum,
483 'phonetypenum' => $phonetypenum,
485 my $contact_phone = qsearchs('contact_phone', \%cp);
487 my $pv = $self->get($pf);
490 #if new value is empty, delete old entry
492 if ($contact_phone) {
493 $error = $contact_phone->delete;
495 $dbh->rollback if $oldAutoCommit;
502 $contact_phone ||= new FS::contact_phone \%cp;
504 my %cpd = _parse_phonestring( $pv );
505 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
507 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
509 $error = $contact_phone->$method;
511 $dbh->rollback if $oldAutoCommit;
516 if ( defined($self->hashref->{'emailaddress'}) ) {
518 #ineffecient but whatever, how many email addresses can there be?
520 foreach my $contact_email ( $self->contact_email ) {
521 my $error = $contact_email->delete;
523 $dbh->rollback if $oldAutoCommit;
528 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
530 my $contact_email = new FS::contact_email {
531 'contactnum' => $self->contactnum,
532 'emailaddress' => $email,
534 $error = $contact_email->insert;
536 $dbh->rollback if $oldAutoCommit;
544 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
545 #warn " queueing fuzzyfiles update\n"
547 $error = $self->queue_fuzzyfiles_update;
549 $dbh->rollback if $oldAutoCommit;
550 return "updating fuzzy search cache: $error";
554 if ( $cust_contact and (
555 ( $cust_contact->selfservice_access eq ''
556 && $link_hash{selfservice_access}
557 && ! length($self->_password)
559 || $cust_contact->_resend()
563 my $error = $self->send_reset_email( queue=>1 );
565 $dbh->rollback if $oldAutoCommit;
570 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
576 =item _parse_phonestring PHONENUMBER_STRING
578 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
579 with keys 'countrycode', 'phonenum' and 'extension'
581 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
585 sub _parse_phonestring {
588 my($countrycode, $extension) = ('1', '');
591 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
597 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
601 ( 'countrycode' => $countrycode,
602 'phonenum' => $value,
603 'extension' => $extension,
607 =item queue_fuzzyfiles_update
609 Used by insert & replace to update the fuzzy search cache
613 use FS::cust_main::Search;
614 sub queue_fuzzyfiles_update {
617 local $SIG{HUP} = 'IGNORE';
618 local $SIG{INT} = 'IGNORE';
619 local $SIG{QUIT} = 'IGNORE';
620 local $SIG{TERM} = 'IGNORE';
621 local $SIG{TSTP} = 'IGNORE';
622 local $SIG{PIPE} = 'IGNORE';
624 my $oldAutoCommit = $FS::UID::AutoCommit;
625 local $FS::UID::AutoCommit = 0;
628 foreach my $field ( 'first', 'last' ) {
629 my $queue = new FS::queue {
630 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
632 my @args = "contact.$field", $self->get($field);
633 my $error = $queue->insert( @args );
635 $dbh->rollback if $oldAutoCommit;
636 return "queueing job (transaction rolled back): $error";
640 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
647 Checks all fields to make sure this is a valid contact. If there is
648 an error, returns the error, otherwise returns false. Called by the insert
656 if ( $self->selfservice_access eq 'R' ) {
657 $self->selfservice_access('Y');
662 $self->ut_numbern('contactnum')
663 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
664 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
665 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
666 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
667 || $self->ut_namen('last')
668 || $self->ut_namen('first')
669 || $self->ut_textn('title')
670 || $self->ut_textn('comment')
671 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
672 || $self->ut_textn('_password')
673 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
674 || $self->ut_enum('disabled', [ '', 'Y' ])
676 return $error if $error;
678 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
680 return "One of first name, last name, or title must have a value"
681 if ! grep $self->$_(), qw( first last title);
688 Returns a formatted string representing this contact, including name, title and
695 my $data = $self->first. ' '. $self->last;
696 $data .= ', '. $self->title
698 $data .= ' ('. $self->comment. ')'
705 Returns a formatted string representing this contact, with just the name.
711 $self->first . ' ' . $self->last;
714 #=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
716 #Returns the name of this contact's class for the specified prospect or
717 #customer (see L<FS::prospect_contact>, L<FS::cust_contact> and
718 #L<FS::contact_class>).
722 #sub contact_classname {
723 # my( $self, $prospect_or_cust ) = @_;
726 # if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) {
727 # $link = qsearchs('prospect_contact', {
728 # 'contactnum' => $self->contactnum,
729 # 'prospectnum' => $prospect_or_cust->prospectnum,
731 # } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) {
732 # $link = qsearchs('cust_contact', {
733 # 'contactnum' => $self->contactnum,
734 # 'custnum' => $prospect_or_cust->custnum,
737 # croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
740 # my $contact_class = $link->contact_class or return '';
741 # $contact_class->classname;
744 #autoloaded by FK in 4.x, but not during the upgrade
747 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
750 =item by_selfservice_email EMAILADDRESS
752 Alternate search constructor (class method). Given an email address, returns
753 the contact for that address. If that contact doesn't have selfservice access,
754 or there isn't one, returns the empty string.
758 sub by_selfservice_email {
759 my($class, $email) = @_;
761 my $contact_email = qsearchs({
762 'table' => 'contact_email',
763 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
764 'hashref' => { 'emailaddress' => $email, },
765 'extra_sql' => " AND ( contact.disabled IS NULL ) ".
766 " AND ( contact.selfservice_access = 'Y' )",
769 $contact_email->contact;
773 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
774 # and should maybe be libraried in some way for other password needs
776 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
778 sub authenticate_password {
779 my($self, $check_password) = @_;
781 if ( $self->_password_encoding eq 'bcrypt' ) {
783 my( $cost, $salt, $hash ) = split(',', $self->_password);
785 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
787 salt => de_base64($salt),
793 $hash eq $check_hash;
797 return 0 if $self->_password eq '';
799 $self->_password eq $check_password;
805 =item change_password NEW_PASSWORD
807 Changes the contact's selfservice access password to NEW_PASSWORD. This does
808 not check password policy rules (see C<is_password_allowed>) and will return
809 an error only if editing the record fails for some reason.
811 If NEW_PASSWORD is the same as the existing password, this does nothing.
815 sub change_password {
816 my($self, $new_password) = @_;
818 # do nothing if the password is unchanged
819 return if $self->authenticate_password($new_password);
821 $self->change_password_fields( $new_password );
827 sub change_password_fields {
828 my($self, $new_password) = @_;
830 $self->_password_encoding('bcrypt');
834 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
836 my $hash = bcrypt_hash( { key_nul => 1,
844 join(',', $cost, en_base64($salt), en_base64($hash) )
849 # end of false laziness w/FS/FS/Auth/internal.pm
852 #false laziness w/ClientAPI/MyAccount/reset_passwd
853 use Digest::SHA qw(sha512_hex);
855 use FS::ClientAPI_SessionCache;
856 sub send_reset_email {
857 my( $self, %opt ) = @_;
859 my @contact_email = $self->contact_email or return '';
861 my $reset_session = {
862 'contactnum' => $self->contactnum,
863 'svcnum' => $opt{'svcnum'},
866 my $timeout = '24 hours'; #?
868 my $reset_session_id;
870 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
871 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
874 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
878 my $conf = new FS::Conf;
881 my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
882 $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
884 my $agentnum = $cust_main ? $cust_main->agentnum : '';
885 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
886 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
887 return "selfservice-password_reset_msgnum unset" unless $msgnum;
888 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
889 return "selfservice-password_reset_msgnum cannot be loaded" unless $msg_template;
891 'to' => join(',', map $_->emailaddress, @contact_email ),
892 'cust_main' => $cust_main,
894 'substitutions' => { 'session_id' => $reset_session_id }
897 if ( $opt{'queue'} ) { #or should queueing just be the default?
899 my $cust_msg = $msg_template->prepare( %msg_template );
900 my $error = $cust_msg->insert;
901 return $error if $error;
902 my $queue = new FS::queue {
903 'job' => 'FS::cust_msg::process_send',
904 'custnum' => $cust_main ? $cust_main->custnum : '',
906 $queue->insert( $cust_msg->custmsgnum );
910 $msg_template->send( %msg_template );
916 use vars qw( $myaccount_cache );
917 sub myaccount_cache {
919 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
920 'namespace' => 'FS::ClientAPI::MyAccount',
924 =item cgi_contact_fields
926 Returns a list reference containing the set of contact fields used in the web
927 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
928 and locationnum, as well as password fields, but including fields for
929 contact_email and contact_phone records.)
933 sub cgi_contact_fields {
936 my @contact_fields = qw(
937 classnum first last title comment emailaddress selfservice_access
941 push @contact_fields, 'phonetypenum'. $_->phonetypenum
942 foreach qsearch({table=>'phone_type', order_by=>'weight'});
948 use FS::upgrade_journal;
949 sub _upgrade_data { #class method
950 my ($class, %opts) = @_;
952 # always migrate cust_main_invoice records over
953 local $FS::cust_main::import = 1; # override require_phone and such
954 my $search = FS::Cursor->new('cust_main_invoice', {});
956 while (my $cust_main_invoice = $search->fetch) {
957 my $custnum = $cust_main_invoice->custnum;
958 my $dest = $cust_main_invoice->dest;
959 my $cust_main = $cust_main_invoice->cust_main;
961 if ( $dest =~ /^\d+$/ ) {
962 my $svc_acct = FS::svc_acct->by_key($dest);
963 die "custnum $custnum, invoice destination svcnum $svc_acct does not exist\n"
965 $dest = $svc_acct->email;
967 push @{ $custnum_dest{$custnum} ||= [] }, $dest;
969 my $error = $cust_main_invoice->delete;
971 die "custnum $custnum, cleaning up cust_main_invoice: $error\n";
975 foreach my $custnum (keys %custnum_dest) {
976 my $dests = $custnum_dest{$custnum};
977 my $cust_main = FS::cust_main->by_key($custnum);
978 my $error = $cust_main->replace( invoicing_list => $dests );
980 die "custnum $custnum, creating contact: $error\n";
984 unless ( FS::upgrade_journal->is_done('contact_invoice_dest') ) {
986 local($skip_fuzzyfiles) = 1;
988 foreach my $contact (qsearch('contact', {})) {
989 my $error = $contact->replace;
990 die $error if $error;
993 FS::upgrade_journal->set_done('contact_invoice_dest');
1004 L<FS::Record>, schema.html from the base documentation.