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 if ( $self->get('password') ) {
374 my $error = $self->is_password_allowed($self->get('password'))
375 || $self->change_password($self->get('password'));
377 $dbh->rollback if $oldAutoCommit;
382 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
388 =item _parse_phonestring PHONENUMBER_STRING
390 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
391 with keys 'countrycode', 'phonenum' and 'extension'
393 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
397 sub _parse_phonestring {
400 my($countrycode, $extension) = ('1', '');
403 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
409 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
413 ( 'countrycode' => $countrycode,
414 'phonenum' => $value,
415 'extension' => $extension,
419 =item queue_fuzzyfiles_update
421 Used by insert & replace to update the fuzzy search cache
425 use FS::cust_main::Search;
426 sub queue_fuzzyfiles_update {
429 local $SIG{HUP} = 'IGNORE';
430 local $SIG{INT} = 'IGNORE';
431 local $SIG{QUIT} = 'IGNORE';
432 local $SIG{TERM} = 'IGNORE';
433 local $SIG{TSTP} = 'IGNORE';
434 local $SIG{PIPE} = 'IGNORE';
436 my $oldAutoCommit = $FS::UID::AutoCommit;
437 local $FS::UID::AutoCommit = 0;
440 foreach my $field ( 'first', 'last' ) {
441 my $queue = new FS::queue {
442 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
444 my @args = "contact.$field", $self->get($field);
445 my $error = $queue->insert( @args );
447 $dbh->rollback if $oldAutoCommit;
448 return "queueing job (transaction rolled back): $error";
452 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
459 Checks all fields to make sure this is a valid contact. If there is
460 an error, returns the error, otherwise returns false. Called by the insert
468 if ( $self->selfservice_access eq 'R' || $self->selfservice_access eq 'E' || $self->selfservice_access eq 'P' ) {
469 $self->selfservice_access('Y');
474 $self->ut_numbern('contactnum')
475 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
476 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
477 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
478 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
479 || $self->ut_namen('last')
480 || $self->ut_namen('first')
481 || $self->ut_textn('title')
482 || $self->ut_textn('comment')
483 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
484 || $self->ut_textn('_password')
485 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
486 || $self->ut_enum('disabled', [ '', 'Y' ])
488 return $error if $error;
490 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
491 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
493 return "One of first name, last name, or title must have a value"
494 if ! grep $self->$_(), qw( first last title);
501 Returns a formatted string representing this contact, including name, title and
508 my $data = $self->first. ' '. $self->last;
509 $data .= ', '. $self->title
511 $data .= ' ('. $self->comment. ')'
518 return '' unless $self->locationnum;
519 qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
524 return '' unless $self->classnum;
525 qsearchs('contact_class', { 'classnum' => $self->classnum } );
530 Returns a formatted string representing this contact, with just the name.
536 $self->first . ' ' . $self->last;
539 =item contact_classname
541 Returns the name of this contact's class (see L<FS::contact_class>).
545 sub contact_classname {
547 my $contact_class = $self->contact_class or return '';
548 $contact_class->classname;
553 qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
558 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
563 qsearchs('cust_main', { 'custnum' => $self->custnum } );
568 qsearch('cust_pkg', { 'contactnum' => $self->contactnum } );
571 =item by_selfservice_email EMAILADDRESS
573 Alternate search constructor (class method). Given an email address,
574 returns the contact for that address, or the empty string if no contact
575 has that email address.
579 sub by_selfservice_email {
580 my($class, $email) = @_;
582 my $contact_email = qsearchs({
583 'table' => 'contact_email',
584 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
585 'hashref' => { 'emailaddress' => $email, },
586 'extra_sql' => " AND selfservice_access = 'Y' ".
587 " AND ( disabled IS NULL OR disabled = '' )",
590 $contact_email->contact;
594 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
595 # and should maybe be libraried in some way for other password needs
597 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
599 sub authenticate_password {
600 my($self, $check_password) = @_;
602 if ( $self->_password_encoding eq 'bcrypt' ) {
604 my( $cost, $salt, $hash ) = split(',', $self->_password);
606 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
608 salt => de_base64($salt),
614 $hash eq $check_hash;
618 return 0 if $self->_password eq '';
620 $self->_password eq $check_password;
626 =item change_password NEW_PASSWORD
628 Changes the contact's selfservice access password to NEW_PASSWORD. This does
629 not check password policy rules (see C<is_password_allowed>) and will return
630 an error only if editing the record fails for some reason.
632 If NEW_PASSWORD is the same as the existing password, this does nothing.
636 sub change_password {
637 my($self, $new_password) = @_;
639 # do nothing if the password is unchanged
640 return if $self->authenticate_password($new_password);
642 $self->change_password_fields( $new_password );
648 sub change_password_fields {
649 my($self, $new_password) = @_;
651 $self->_password_encoding('bcrypt');
655 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
657 my $hash = bcrypt_hash( { key_nul => 1,
665 join(',', $cost, en_base64($salt), en_base64($hash) )
670 # end of false laziness w/FS/FS/Auth/internal.pm
673 #false laziness w/ClientAPI/MyAccount/reset_passwd
674 use Digest::SHA qw(sha512_hex);
676 use FS::ClientAPI_SessionCache;
677 sub send_reset_email {
678 my( $self, %opt ) = @_;
680 my @contact_email = $self->contact_email or return '';
682 my $reset_session = {
683 'contactnum' => $self->contactnum,
684 'svcnum' => $opt{'svcnum'},
688 my $conf = new FS::Conf;
690 ($conf->config('selfservice-password_reset_hours') || 24 ). ' hours';
692 my $reset_session_id;
694 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
695 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
698 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
702 my $cust_main = $self->cust_main
703 or die "no customer"; #reset a password for a prospect contact? someday
705 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
706 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
707 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
708 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
710 'to' => join(',', map $_->emailaddress, @contact_email ),
711 'cust_main' => $cust_main,
713 'substitutions' => { 'session_id' => $reset_session_id }
716 if ( $opt{'queue'} ) { #or should queueing just be the default?
718 my $queue = new FS::queue {
719 'job' => 'FS::Misc::process_send_email',
720 'custnum' => $cust_main->custnum,
722 $queue->insert( $msg_template->prepare( %msg_template ) );
726 $msg_template->send( %msg_template );
732 use vars qw( $myaccount_cache );
733 sub myaccount_cache {
735 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
736 'namespace' => 'FS::ClientAPI::MyAccount',
740 =item cgi_contact_fields
742 Returns a list reference containing the set of contact fields used in the web
743 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
744 and locationnum, as well as password fields, but including fields for
745 contact_email and contact_phone records.)
749 sub cgi_contact_fields {
752 my @contact_fields = qw(
753 classnum first last title comment emailaddress selfservice_access
754 invoice_dest password
757 push @contact_fields, 'phonetypenum'. $_->phonetypenum
758 foreach qsearch({table=>'phone_type', order_by=>'weight'});
772 L<FS::Record>, schema.html from the base documentation.