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->SUPER::delete;
238 $dbh->rollback if $oldAutoCommit;
242 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
247 =item replace OLD_RECORD
249 Replaces the OLD_RECORD with this one in the database. If there is an error,
250 returns the error, otherwise returns false.
257 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
259 : $self->replace_old;
261 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
263 local $SIG{INT} = 'IGNORE';
264 local $SIG{QUIT} = 'IGNORE';
265 local $SIG{TERM} = 'IGNORE';
266 local $SIG{TSTP} = 'IGNORE';
267 local $SIG{PIPE} = 'IGNORE';
269 my $oldAutoCommit = $FS::UID::AutoCommit;
270 local $FS::UID::AutoCommit = 0;
273 my $error = $self->SUPER::replace($old);
274 if ( $old->_password ne $self->_password ) {
275 $error ||= $self->insert_password_history;
278 $dbh->rollback if $oldAutoCommit;
282 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
283 keys %{ $self->hashref } ) {
284 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
285 my $phonetypenum = $1;
287 my %cp = ( 'contactnum' => $self->contactnum,
288 'phonetypenum' => $phonetypenum,
290 my $contact_phone = qsearchs('contact_phone', \%cp);
292 my $pv = $self->get($pf);
295 #if new value is empty, delete old entry
297 if ($contact_phone) {
298 $error = $contact_phone->delete;
300 $dbh->rollback if $oldAutoCommit;
307 $contact_phone ||= new FS::contact_phone \%cp;
309 my %cpd = _parse_phonestring( $pv );
310 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
312 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
314 $error = $contact_phone->$method;
316 $dbh->rollback if $oldAutoCommit;
321 if ( defined($self->hashref->{'emailaddress'}) ) {
323 #ineffecient but whatever, how many email addresses can there be?
325 foreach my $contact_email ( $self->contact_email ) {
326 my $error = $contact_email->delete;
328 $dbh->rollback if $oldAutoCommit;
333 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
335 my $contact_email = new FS::contact_email {
336 'contactnum' => $self->contactnum,
337 'emailaddress' => $email,
339 $error = $contact_email->insert;
341 $dbh->rollback if $oldAutoCommit;
349 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
350 #warn " queueing fuzzyfiles update\n"
352 $error = $self->queue_fuzzyfiles_update;
354 $dbh->rollback if $oldAutoCommit;
355 return "updating fuzzy search cache: $error";
359 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
360 && ! $self->_password
365 my $error = $self->send_reset_email( queue=>1 );
367 $dbh->rollback if $oldAutoCommit;
372 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
378 =item _parse_phonestring PHONENUMBER_STRING
380 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
381 with keys 'countrycode', 'phonenum' and 'extension'
383 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
387 sub _parse_phonestring {
390 my($countrycode, $extension) = ('1', '');
393 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
399 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
403 ( 'countrycode' => $countrycode,
404 'phonenum' => $value,
405 'extension' => $extension,
409 =item queue_fuzzyfiles_update
411 Used by insert & replace to update the fuzzy search cache
415 use FS::cust_main::Search;
416 sub queue_fuzzyfiles_update {
419 local $SIG{HUP} = 'IGNORE';
420 local $SIG{INT} = 'IGNORE';
421 local $SIG{QUIT} = 'IGNORE';
422 local $SIG{TERM} = 'IGNORE';
423 local $SIG{TSTP} = 'IGNORE';
424 local $SIG{PIPE} = 'IGNORE';
426 my $oldAutoCommit = $FS::UID::AutoCommit;
427 local $FS::UID::AutoCommit = 0;
430 foreach my $field ( 'first', 'last' ) {
431 my $queue = new FS::queue {
432 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
434 my @args = "contact.$field", $self->get($field);
435 my $error = $queue->insert( @args );
437 $dbh->rollback if $oldAutoCommit;
438 return "queueing job (transaction rolled back): $error";
442 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
449 Checks all fields to make sure this is a valid contact. If there is
450 an error, returns the error, otherwise returns false. Called by the insert
458 if ( $self->selfservice_access eq 'R' ) {
459 $self->selfservice_access('Y');
464 $self->ut_numbern('contactnum')
465 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
466 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
467 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
468 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
469 || $self->ut_namen('last')
470 || $self->ut_namen('first')
471 || $self->ut_textn('title')
472 || $self->ut_textn('comment')
473 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
474 || $self->ut_textn('_password')
475 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
476 || $self->ut_enum('disabled', [ '', 'Y' ])
478 return $error if $error;
480 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
481 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
483 return "One of first name, last name, or title must have a value"
484 if ! grep $self->$_(), qw( first last title);
491 Returns a formatted string representing this contact, including name, title and
498 my $data = $self->first. ' '. $self->last;
499 $data .= ', '. $self->title
501 $data .= ' ('. $self->comment. ')'
508 return '' unless $self->locationnum;
509 qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
514 return '' unless $self->classnum;
515 qsearchs('contact_class', { 'classnum' => $self->classnum } );
520 Returns a formatted string representing this contact, with just the name.
526 $self->first . ' ' . $self->last;
529 =item contact_classname
531 Returns the name of this contact's class (see L<FS::contact_class>).
535 sub contact_classname {
537 my $contact_class = $self->contact_class or return '';
538 $contact_class->classname;
543 qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
548 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
553 qsearchs('cust_main', { 'custnum' => $self->custnum } );
558 qsearch('cust_pkg', { 'contactnum' => $self->contactnum } );
561 =item by_selfservice_email EMAILADDRESS
563 Alternate search constructor (class method). Given an email address,
564 returns the contact for that address, or the empty string if no contact
565 has that email address.
569 sub by_selfservice_email {
570 my($class, $email) = @_;
572 my $contact_email = qsearchs({
573 'table' => 'contact_email',
574 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
575 'hashref' => { 'emailaddress' => $email, },
576 'extra_sql' => " AND selfservice_access = 'Y' ".
577 " AND ( disabled IS NULL OR disabled = '' )",
580 $contact_email->contact;
584 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
585 # and should maybe be libraried in some way for other password needs
587 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
589 sub authenticate_password {
590 my($self, $check_password) = @_;
592 if ( $self->_password_encoding eq 'bcrypt' ) {
594 my( $cost, $salt, $hash ) = split(',', $self->_password);
596 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
598 salt => de_base64($salt),
604 $hash eq $check_hash;
608 return 0 if $self->_password eq '';
610 $self->_password eq $check_password;
616 =item change_password NEW_PASSWORD
618 Changes the contact's selfservice access password to NEW_PASSWORD. This does
619 not check password policy rules (see C<is_password_allowed>) and will return
620 an error only if editing the record fails for some reason.
622 If NEW_PASSWORD is the same as the existing password, this does nothing.
626 sub change_password {
627 my($self, $new_password) = @_;
629 # do nothing if the password is unchanged
630 return if $self->authenticate_password($new_password);
632 $self->change_password_fields( $new_password );
638 sub change_password_fields {
639 my($self, $new_password) = @_;
641 $self->_password_encoding('bcrypt');
645 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
647 my $hash = bcrypt_hash( { key_nul => 1,
655 join(',', $cost, en_base64($salt), en_base64($hash) )
660 # end of false laziness w/FS/FS/Auth/internal.pm
663 #false laziness w/ClientAPI/MyAccount/reset_passwd
664 use Digest::SHA qw(sha512_hex);
666 use FS::ClientAPI_SessionCache;
667 sub send_reset_email {
668 my( $self, %opt ) = @_;
670 my @contact_email = $self->contact_email or return '';
672 my $reset_session = {
673 'contactnum' => $self->contactnum,
674 'svcnum' => $opt{'svcnum'},
677 my $timeout = '24 hours'; #?
679 my $reset_session_id;
681 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
682 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
685 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
689 my $conf = new FS::Conf;
691 my $cust_main = $self->cust_main
692 or die "no customer"; #reset a password for a prospect contact? someday
694 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
695 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
696 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
697 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
699 'to' => join(',', map $_->emailaddress, @contact_email ),
700 'cust_main' => $cust_main,
702 'substitutions' => { 'session_id' => $reset_session_id }
705 if ( $opt{'queue'} ) { #or should queueing just be the default?
707 my $queue = new FS::queue {
708 'job' => 'FS::Misc::process_send_email',
709 'custnum' => $cust_main->custnum,
711 $queue->insert( $msg_template->prepare( %msg_template ) );
715 $msg_template->send( %msg_template );
721 use vars qw( $myaccount_cache );
722 sub myaccount_cache {
724 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
725 'namespace' => 'FS::ClientAPI::MyAccount',
729 =item cgi_contact_fields
731 Returns a list reference containing the set of contact fields used in the web
732 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
733 and locationnum, as well as password fields, but including fields for
734 contact_email and contact_phone records.)
738 sub cgi_contact_fields {
741 my @contact_fields = qw(
742 classnum first last title comment emailaddress selfservice_access
745 push @contact_fields, 'phonetypenum'. $_->phonetypenum
746 foreach qsearch({table=>'phone_type', order_by=>'weight'});
760 L<FS::Record>, schema.html from the base documentation.