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;
17 use FS::phone_type; #for cgi_contact_fields
23 FS::contact - Object methods for contact records
29 $record = new FS::contact \%hash;
30 $record = new FS::contact { 'column' => 'value' };
32 $error = $record->insert;
34 $error = $new_record->replace($old_record);
36 $error = $record->delete;
38 $error = $record->check;
42 An FS::contact object represents an specific contact person for a prospect or
43 customer. FS::contact inherits from FS::Record. The following fields are
80 =item selfservice_access
86 =item _password_encoding
103 Creates a new contact. To add the contact to the database, see L<"insert">.
105 Note that this stores the hash reference, not a distinct copy of the hash it
106 points to. You can ask the object for a copy with the I<hash> method.
110 sub table { 'contact'; }
114 Adds this record to the database. If there is an error, returns the error,
115 otherwise returns false.
122 local $SIG{INT} = 'IGNORE';
123 local $SIG{QUIT} = 'IGNORE';
124 local $SIG{TERM} = 'IGNORE';
125 local $SIG{TSTP} = 'IGNORE';
126 local $SIG{PIPE} = 'IGNORE';
128 my $oldAutoCommit = $FS::UID::AutoCommit;
129 local $FS::UID::AutoCommit = 0;
132 my $error = $self->SUPER::insert;
133 $error ||= $self->insert_password_history;
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 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
201 Delete this record from the database.
208 local $SIG{HUP} = 'IGNORE';
209 local $SIG{INT} = 'IGNORE';
210 local $SIG{QUIT} = 'IGNORE';
211 local $SIG{TERM} = 'IGNORE';
212 local $SIG{TSTP} = 'IGNORE';
213 local $SIG{PIPE} = 'IGNORE';
215 my $oldAutoCommit = $FS::UID::AutoCommit;
216 local $FS::UID::AutoCommit = 0;
219 foreach my $cust_pkg ( $self->cust_pkg ) {
220 $cust_pkg->contactnum('');
221 my $error = $cust_pkg->replace;
223 $dbh->rollback if $oldAutoCommit;
228 foreach my $object ( $self->contact_phone, $self->contact_email ) {
229 my $error = $object->delete;
231 $dbh->rollback if $oldAutoCommit;
236 my $error = $self->delete_password_history
237 || $self->SUPER::delete;
239 $dbh->rollback if $oldAutoCommit;
243 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
248 =item replace OLD_RECORD
250 Replaces the OLD_RECORD with this one in the database. If there is an error,
251 returns the error, otherwise returns false.
258 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
260 : $self->replace_old;
262 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
264 local $SIG{INT} = 'IGNORE';
265 local $SIG{QUIT} = 'IGNORE';
266 local $SIG{TERM} = 'IGNORE';
267 local $SIG{TSTP} = 'IGNORE';
268 local $SIG{PIPE} = 'IGNORE';
270 my $oldAutoCommit = $FS::UID::AutoCommit;
271 local $FS::UID::AutoCommit = 0;
274 my $error = $self->SUPER::replace($old);
275 if ( $old->_password ne $self->_password ) {
276 $error ||= $self->insert_password_history;
279 $dbh->rollback if $oldAutoCommit;
283 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
284 keys %{ $self->hashref } ) {
285 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
286 my $phonetypenum = $1;
288 my %cp = ( 'contactnum' => $self->contactnum,
289 'phonetypenum' => $phonetypenum,
291 my $contact_phone = qsearchs('contact_phone', \%cp);
293 my $pv = $self->get($pf);
296 #if new value is empty, delete old entry
298 if ($contact_phone) {
299 $error = $contact_phone->delete;
301 $dbh->rollback if $oldAutoCommit;
308 $contact_phone ||= new FS::contact_phone \%cp;
310 my %cpd = _parse_phonestring( $pv );
311 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
313 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
315 $error = $contact_phone->$method;
317 $dbh->rollback if $oldAutoCommit;
322 if ( defined($self->hashref->{'emailaddress'}) ) {
324 #ineffecient but whatever, how many email addresses can there be?
326 foreach my $contact_email ( $self->contact_email ) {
327 my $error = $contact_email->delete;
329 $dbh->rollback if $oldAutoCommit;
334 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
336 my $contact_email = new FS::contact_email {
337 'contactnum' => $self->contactnum,
338 'emailaddress' => $email,
340 $error = $contact_email->insert;
342 $dbh->rollback if $oldAutoCommit;
350 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
351 #warn " queueing fuzzyfiles update\n"
353 $error = $self->queue_fuzzyfiles_update;
355 $dbh->rollback if $oldAutoCommit;
356 return "updating fuzzy search cache: $error";
360 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
361 && ! $self->_password
366 my $error = $self->send_reset_email( queue=>1 );
368 $dbh->rollback if $oldAutoCommit;
373 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
379 =item _parse_phonestring PHONENUMBER_STRING
381 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
382 with keys 'countrycode', 'phonenum' and 'extension'
384 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
388 sub _parse_phonestring {
391 my($countrycode, $extension) = ('1', '');
394 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
400 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
404 ( 'countrycode' => $countrycode,
405 'phonenum' => $value,
406 'extension' => $extension,
410 =item queue_fuzzyfiles_update
412 Used by insert & replace to update the fuzzy search cache
416 use FS::cust_main::Search;
417 sub queue_fuzzyfiles_update {
420 local $SIG{HUP} = 'IGNORE';
421 local $SIG{INT} = 'IGNORE';
422 local $SIG{QUIT} = 'IGNORE';
423 local $SIG{TERM} = 'IGNORE';
424 local $SIG{TSTP} = 'IGNORE';
425 local $SIG{PIPE} = 'IGNORE';
427 my $oldAutoCommit = $FS::UID::AutoCommit;
428 local $FS::UID::AutoCommit = 0;
431 foreach my $field ( 'first', 'last' ) {
432 my $queue = new FS::queue {
433 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
435 my @args = "contact.$field", $self->get($field);
436 my $error = $queue->insert( @args );
438 $dbh->rollback if $oldAutoCommit;
439 return "queueing job (transaction rolled back): $error";
443 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
450 Checks all fields to make sure this is a valid contact. If there is
451 an error, returns the error, otherwise returns false. Called by the insert
459 if ( $self->selfservice_access eq 'R' ) {
460 $self->selfservice_access('Y');
465 $self->ut_numbern('contactnum')
466 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
467 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
468 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
469 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
470 || $self->ut_namen('last')
471 || $self->ut_namen('first')
472 || $self->ut_textn('title')
473 || $self->ut_textn('comment')
474 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
475 || $self->ut_textn('_password')
476 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
477 || $self->ut_enum('disabled', [ '', 'Y' ])
479 return $error if $error;
481 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
482 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
484 return "One of first name, last name, or title must have a value"
485 if ! grep $self->$_(), qw( first last title);
492 Returns a formatted string representing this contact, including name, title and
499 my $data = $self->first. ' '. $self->last;
500 $data .= ', '. $self->title
502 $data .= ' ('. $self->comment. ')'
509 return '' unless $self->locationnum;
510 qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
515 return '' unless $self->classnum;
516 qsearchs('contact_class', { 'classnum' => $self->classnum } );
521 Returns a formatted string representing this contact, with just the name.
527 $self->first . ' ' . $self->last;
530 =item contact_classname
532 Returns the name of this contact's class (see L<FS::contact_class>).
536 sub contact_classname {
538 my $contact_class = $self->contact_class or return '';
539 $contact_class->classname;
544 qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
549 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
554 qsearchs('cust_main', { 'custnum' => $self->custnum } );
559 qsearch('cust_pkg', { 'contactnum' => $self->contactnum } );
562 =item by_selfservice_email EMAILADDRESS
564 Alternate search constructor (class method). Given an email address,
565 returns the contact for that address, or the empty string if no contact
566 has that email address.
570 sub by_selfservice_email {
571 my($class, $email) = @_;
573 my $contact_email = qsearchs({
574 'table' => 'contact_email',
575 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
576 'hashref' => { 'emailaddress' => $email, },
577 'extra_sql' => " AND selfservice_access = 'Y' ".
578 " AND ( disabled IS NULL OR disabled = '' )",
581 $contact_email->contact;
585 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
586 # and should maybe be libraried in some way for other password needs
588 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
590 sub authenticate_password {
591 my($self, $check_password) = @_;
593 if ( $self->_password_encoding eq 'bcrypt' ) {
595 my( $cost, $salt, $hash ) = split(',', $self->_password);
597 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
599 salt => de_base64($salt),
605 $hash eq $check_hash;
609 return 0 if $self->_password eq '';
611 $self->_password eq $check_password;
617 =item change_password NEW_PASSWORD
619 Changes the contact's selfservice access password to NEW_PASSWORD. This does
620 not check password policy rules (see C<is_password_allowed>) and will return
621 an error only if editing the record fails for some reason.
623 If NEW_PASSWORD is the same as the existing password, this does nothing.
627 sub change_password {
628 my($self, $new_password) = @_;
630 # do nothing if the password is unchanged
631 return if $self->authenticate_password($new_password);
633 $self->change_password_fields( $new_password );
639 sub change_password_fields {
640 my($self, $new_password) = @_;
642 $self->_password_encoding('bcrypt');
646 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
648 my $hash = bcrypt_hash( { key_nul => 1,
656 join(',', $cost, en_base64($salt), en_base64($hash) )
661 # end of false laziness w/FS/FS/Auth/internal.pm
664 #false laziness w/ClientAPI/MyAccount/reset_passwd
665 use Digest::SHA qw(sha512_hex);
667 use FS::ClientAPI_SessionCache;
668 sub send_reset_email {
669 my( $self, %opt ) = @_;
671 my @contact_email = $self->contact_email or return '';
673 my $reset_session = {
674 'contactnum' => $self->contactnum,
675 'svcnum' => $opt{'svcnum'},
678 my $timeout = '24 hours'; #?
680 my $reset_session_id;
682 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
683 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
686 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
690 my $conf = new FS::Conf;
692 my $cust_main = $self->cust_main
693 or die "no customer"; #reset a password for a prospect contact? someday
695 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
696 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
697 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
698 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
700 'to' => join(',', map $_->emailaddress, @contact_email ),
701 'cust_main' => $cust_main,
703 'substitutions' => { 'session_id' => $reset_session_id }
706 if ( $opt{'queue'} ) { #or should queueing just be the default?
708 my $queue = new FS::queue {
709 'job' => 'FS::Misc::process_send_email',
710 'custnum' => $cust_main->custnum,
712 $queue->insert( $msg_template->prepare( %msg_template ) );
716 $msg_template->send( %msg_template );
722 use vars qw( $myaccount_cache );
723 sub myaccount_cache {
725 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
726 'namespace' => 'FS::ClientAPI::MyAccount',
730 =item cgi_contact_fields
732 Returns a list reference containing the set of contact fields used in the web
733 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
734 and locationnum, as well as password fields, but including fields for
735 contact_email and contact_phone records.)
739 sub cgi_contact_fields {
742 my @contact_fields = qw(
743 classnum first last title comment emailaddress selfservice_access
746 push @contact_fields, 'phonetypenum'. $_->phonetypenum
747 foreach qsearch({table=>'phone_type', order_by=>'weight'});
761 L<FS::Record>, schema.html from the base documentation.