2 use base qw( FS::Password_Mixin
6 use vars qw( $skip_fuzzyfiles );
7 use Scalar::Util qw( blessed );
8 use FS::Record qw( qsearch qsearchs dbh );
11 use FS::contact_class;
12 use FS::cust_location;
13 use FS::contact_phone;
14 use FS::contact_email;
15 use FS::contact::Import;
18 use FS::phone_type; #for cgi_contact_fields
24 FS::contact - Object methods for contact records
30 $record = new FS::contact \%hash;
31 $record = new FS::contact { 'column' => 'value' };
33 $error = $record->insert;
35 $error = $new_record->replace($old_record);
37 $error = $record->delete;
39 $error = $record->check;
43 An FS::contact object represents an specific contact person for a prospect or
44 customer. FS::contact inherits from FS::Record. The following fields are
81 =item selfservice_access
87 =item _password_encoding
104 Creates a new contact. To add the contact to the database, see L<"insert">.
106 Note that this stores the hash reference, not a distinct copy of the hash it
107 points to. You can ask the object for a copy with the I<hash> method.
111 sub table { 'contact'; }
115 Adds this record to the database. If there is an error, returns the error,
116 otherwise returns false.
123 local $SIG{INT} = 'IGNORE';
124 local $SIG{QUIT} = 'IGNORE';
125 local $SIG{TERM} = 'IGNORE';
126 local $SIG{TSTP} = 'IGNORE';
127 local $SIG{PIPE} = 'IGNORE';
129 my $oldAutoCommit = $FS::UID::AutoCommit;
130 local $FS::UID::AutoCommit = 0;
133 my $error = $self->SUPER::insert;
136 $dbh->rollback if $oldAutoCommit;
140 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
141 keys %{ $self->hashref } ) {
142 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
143 my $phonetypenum = $1;
145 my $contact_phone = new FS::contact_phone {
146 'contactnum' => $self->contactnum,
147 'phonetypenum' => $phonetypenum,
148 _parse_phonestring( $self->get($pf) ),
150 $error = $contact_phone->insert;
152 $dbh->rollback if $oldAutoCommit;
157 if ( $self->get('emailaddress') =~ /\S/ ) {
159 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
161 my $contact_email = new FS::contact_email {
162 'contactnum' => $self->contactnum,
163 'emailaddress' => $email,
165 $error = $contact_email->insert;
167 $dbh->rollback if $oldAutoCommit;
175 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
176 #warn " queueing fuzzyfiles update\n"
178 $error = $self->queue_fuzzyfiles_update;
180 $dbh->rollback if $oldAutoCommit;
181 return "updating fuzzy search cache: $error";
185 if ( $self->selfservice_access && ! length($self->_password) ) {
186 my $error = $self->send_reset_email( queue=>1 );
188 $dbh->rollback if $oldAutoCommit;
193 if ( $self->get('password') ) {
194 my $error = $self->is_password_allowed($self->get('password'))
195 || $self->change_password($self->get('password'));
197 $dbh->rollback if $oldAutoCommit;
202 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
210 Delete this record from the database.
217 local $SIG{HUP} = 'IGNORE';
218 local $SIG{INT} = 'IGNORE';
219 local $SIG{QUIT} = 'IGNORE';
220 local $SIG{TERM} = 'IGNORE';
221 local $SIG{TSTP} = 'IGNORE';
222 local $SIG{PIPE} = 'IGNORE';
224 my $oldAutoCommit = $FS::UID::AutoCommit;
225 local $FS::UID::AutoCommit = 0;
228 foreach my $cust_pkg ( $self->cust_pkg ) {
229 $cust_pkg->contactnum('');
230 my $error = $cust_pkg->replace;
232 $dbh->rollback if $oldAutoCommit;
237 foreach my $object ( $self->contact_phone, $self->contact_email ) {
238 my $error = $object->delete;
240 $dbh->rollback if $oldAutoCommit;
245 my $error = $self->delete_password_history
246 || $self->SUPER::delete;
248 $dbh->rollback if $oldAutoCommit;
252 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
257 =item replace OLD_RECORD
259 Replaces the OLD_RECORD with this one in the database. If there is an error,
260 returns the error, otherwise returns false.
267 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
269 : $self->replace_old;
271 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
273 local $SIG{INT} = 'IGNORE';
274 local $SIG{QUIT} = 'IGNORE';
275 local $SIG{TERM} = 'IGNORE';
276 local $SIG{TSTP} = 'IGNORE';
277 local $SIG{PIPE} = 'IGNORE';
279 my $oldAutoCommit = $FS::UID::AutoCommit;
280 local $FS::UID::AutoCommit = 0;
283 my $error = $self->SUPER::replace($old);
284 if ( $old->_password ne $self->_password ) {
285 $error ||= $self->insert_password_history;
288 $dbh->rollback if $oldAutoCommit;
292 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
293 keys %{ $self->hashref } ) {
294 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
295 my $phonetypenum = $1;
297 my %cp = ( 'contactnum' => $self->contactnum,
298 'phonetypenum' => $phonetypenum,
300 my $contact_phone = qsearchs('contact_phone', \%cp);
302 my $pv = $self->get($pf);
305 #if new value is empty, delete old entry
307 if ($contact_phone) {
308 $error = $contact_phone->delete;
310 $dbh->rollback if $oldAutoCommit;
317 $contact_phone ||= new FS::contact_phone \%cp;
319 my %cpd = _parse_phonestring( $pv );
320 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
322 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
324 $error = $contact_phone->$method;
326 $dbh->rollback if $oldAutoCommit;
331 if ( defined($self->hashref->{'emailaddress'}) ) {
333 #ineffecient but whatever, how many email addresses can there be?
335 foreach my $contact_email ( $self->contact_email ) {
336 my $error = $contact_email->delete;
338 $dbh->rollback if $oldAutoCommit;
343 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
345 my $contact_email = new FS::contact_email {
346 'contactnum' => $self->contactnum,
347 'emailaddress' => $email,
349 $error = $contact_email->insert;
351 $dbh->rollback if $oldAutoCommit;
359 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
360 #warn " queueing fuzzyfiles update\n"
362 $error = $self->queue_fuzzyfiles_update;
364 $dbh->rollback if $oldAutoCommit;
365 return "updating fuzzy search cache: $error";
369 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
370 && ! $self->_password
375 my $error = $self->send_reset_email( queue=>1 );
377 $dbh->rollback if $oldAutoCommit;
382 if ( $self->get('password') ) {
383 my $error = $self->is_password_allowed($self->get('password'))
384 || $self->change_password($self->get('password'));
386 $dbh->rollback if $oldAutoCommit;
391 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
397 =item _parse_phonestring PHONENUMBER_STRING
399 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
400 with keys 'countrycode', 'phonenum' and 'extension'
402 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
406 sub _parse_phonestring {
409 my($countrycode, $extension) = ('1', '');
412 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
418 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
422 ( 'countrycode' => $countrycode,
423 'phonenum' => $value,
424 'extension' => $extension,
428 =item queue_fuzzyfiles_update
430 Used by insert & replace to update the fuzzy search cache
434 use FS::cust_main::Search;
435 sub queue_fuzzyfiles_update {
438 local $SIG{HUP} = 'IGNORE';
439 local $SIG{INT} = 'IGNORE';
440 local $SIG{QUIT} = 'IGNORE';
441 local $SIG{TERM} = 'IGNORE';
442 local $SIG{TSTP} = 'IGNORE';
443 local $SIG{PIPE} = 'IGNORE';
445 my $oldAutoCommit = $FS::UID::AutoCommit;
446 local $FS::UID::AutoCommit = 0;
449 foreach my $field ( 'first', 'last' ) {
450 my $queue = new FS::queue {
451 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
453 my @args = "contact.$field", $self->get($field);
454 my $error = $queue->insert( @args );
456 $dbh->rollback if $oldAutoCommit;
457 return "queueing job (transaction rolled back): $error";
461 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
468 Checks all fields to make sure this is a valid contact. If there is
469 an error, returns the error, otherwise returns false. Called by the insert
477 if ( $self->selfservice_access eq 'R' || $self->selfservice_access eq 'E' || $self->selfservice_access eq 'P' ) {
478 $self->selfservice_access('Y');
483 $self->ut_numbern('contactnum')
484 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
485 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
486 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
487 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
488 || $self->ut_namen('last')
489 || $self->ut_namen('first')
490 || $self->ut_textn('title')
491 || $self->ut_textn('comment')
492 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
493 || $self->ut_textn('_password')
494 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
495 || $self->ut_enum('disabled', [ '', 'Y' ])
497 return $error if $error;
499 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
500 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
502 return "One of first name, last name, or title must have a value"
503 if ! grep $self->$_(), qw( first last title);
510 Returns a formatted string representing this contact, including name, title and
517 my $data = $self->first. ' '. $self->last;
518 $data .= ', '. $self->title
520 $data .= ' ('. $self->comment. ')'
527 return '' unless $self->locationnum;
528 qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
533 return '' unless $self->classnum;
534 qsearchs('contact_class', { 'classnum' => $self->classnum } );
539 Returns a formatted string representing this contact, with just the name.
545 $self->first . ' ' . $self->last;
548 =item contact_classname
550 Returns the name of this contact's class (see L<FS::contact_class>).
554 sub contact_classname {
556 my $contact_class = $self->contact_class or return '';
557 $contact_class->classname;
562 qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
567 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
572 qsearchs('cust_main', { 'custnum' => $self->custnum } );
577 qsearch('cust_pkg', { 'contactnum' => $self->contactnum } );
580 =item by_selfservice_email EMAILADDRESS
582 Alternate search constructor (class method). Given an email address,
583 returns the contact for that address, or the empty string if no contact
584 has that email address.
588 sub by_selfservice_email {
589 my($class, $email, $case_insensitive) = @_;
591 my $email_search = "emailaddress = '".$email."'";
592 $email_search = "LOWER(emailaddress) = LOWER('".$email."')" if $case_insensitive;
594 my $contact_email = qsearchs({
595 'table' => 'contact_email',
596 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
597 'extra_sql' => " WHERE $email_search".
598 " AND selfservice_access = 'Y' ".
599 " AND ( disabled IS NULL OR disabled = '' )",
602 $contact_email->contact;
606 =item by_selfservice_email_custnum EMAILADDRESS, CUSTNUM
608 Alternate search constructor (class method). Given an email address and custnum, returns
609 the contact for that address and custnum. If that contact doesn't have selfservice access,
610 or there isn't one, returns the empty string.
614 sub by_selfservice_email_custnum {
615 my($class, $email, $custnum) = @_;
617 my $contact_email = qsearchs({
618 'table' => 'contact_email',
619 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
620 'hashref' => { 'emailaddress' => $email, },
621 'extra_sql' => " AND selfservice_access = 'Y' ".
622 " AND ( disabled IS NULL OR disabled = '' )",
625 $contact_email->contact;
629 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
630 # and should maybe be libraried in some way for other password needs
632 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
634 sub authenticate_password {
635 my($self, $check_password) = @_;
637 if ( $self->_password_encoding eq 'bcrypt' ) {
639 my( $cost, $salt, $hash ) = split(',', $self->_password);
641 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
643 salt => de_base64($salt),
649 $hash eq $check_hash;
653 return 0 if $self->_password eq '';
655 $self->_password eq $check_password;
661 =item change_password NEW_PASSWORD
663 Changes the contact's selfservice access password to NEW_PASSWORD. This does
664 not check password policy rules (see C<is_password_allowed>) and will return
665 an error only if editing the record fails for some reason.
667 If NEW_PASSWORD is the same as the existing password, this does nothing.
671 sub change_password {
672 my($self, $new_password) = @_;
674 # do nothing if the password is unchanged
675 return if $self->authenticate_password($new_password);
677 $self->change_password_fields( $new_password );
683 sub change_password_fields {
684 my($self, $new_password) = @_;
686 $self->_password_encoding('bcrypt');
690 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
692 my $hash = bcrypt_hash( { key_nul => 1,
700 join(',', $cost, en_base64($salt), en_base64($hash) )
705 # end of false laziness w/FS/FS/Auth/internal.pm
708 #false laziness w/ClientAPI/MyAccount/reset_passwd
709 use Digest::SHA qw(sha512_hex);
711 use FS::ClientAPI_SessionCache;
712 sub send_reset_email {
713 my( $self, %opt ) = @_;
715 my @contact_email = $self->contact_email or return '';
717 my $reset_session = {
718 'contactnum' => $self->contactnum,
719 'svcnum' => $opt{'svcnum'},
723 my $conf = new FS::Conf;
725 ($conf->config('selfservice-password_reset_hours') || 24 ). ' hours';
727 my $reset_session_id;
729 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
730 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
733 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
737 my $cust_main = $self->cust_main
738 or die "no customer"; #reset a password for a prospect contact? someday
740 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
741 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
742 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
743 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
745 'to' => join(',', map $_->emailaddress, @contact_email ),
746 'cust_main' => $cust_main,
748 'substitutions' => { 'session_id' => $reset_session_id }
751 if ( $opt{'queue'} ) { #or should queueing just be the default?
753 my $queue = new FS::queue {
754 'job' => 'FS::Misc::process_send_email',
755 'custnum' => $cust_main->custnum,
757 $queue->insert( $msg_template->prepare( %msg_template ) );
761 $msg_template->send( %msg_template );
767 use vars qw( $myaccount_cache );
768 sub myaccount_cache {
770 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
771 'namespace' => 'FS::ClientAPI::MyAccount',
775 =item cgi_contact_fields
777 Returns a list reference containing the set of contact fields used in the web
778 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
779 and locationnum, as well as password fields, but including fields for
780 contact_email and contact_phone records.)
784 sub cgi_contact_fields {
787 my @contact_fields = qw(
788 classnum first last title comment emailaddress selfservice_access
789 invoice_dest password
792 push @contact_fields, 'phonetypenum'. $_->phonetypenum
793 foreach qsearch({table=>'phone_type', order_by=>'weight'});
807 L<FS::Record>, schema.html from the base documentation.