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;
134 $error ||= $self->insert_password_history;
137 $dbh->rollback if $oldAutoCommit;
141 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
142 keys %{ $self->hashref } ) {
143 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
144 my $phonetypenum = $1;
146 my $contact_phone = new FS::contact_phone {
147 'contactnum' => $self->contactnum,
148 'phonetypenum' => $phonetypenum,
149 _parse_phonestring( $self->get($pf) ),
151 $error = $contact_phone->insert;
153 $dbh->rollback if $oldAutoCommit;
158 if ( $self->get('emailaddress') =~ /\S/ ) {
160 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
162 my $contact_email = new FS::contact_email {
163 'contactnum' => $self->contactnum,
164 'emailaddress' => $email,
166 $error = $contact_email->insert;
168 $dbh->rollback if $oldAutoCommit;
176 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
177 #warn " queueing fuzzyfiles update\n"
179 $error = $self->queue_fuzzyfiles_update;
181 $dbh->rollback if $oldAutoCommit;
182 return "updating fuzzy search cache: $error";
186 if ( $self->selfservice_access && ! length($self->_password) ) {
187 my $error = $self->send_reset_email( queue=>1 );
189 $dbh->rollback if $oldAutoCommit;
194 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
202 Delete this record from the database.
209 local $SIG{HUP} = 'IGNORE';
210 local $SIG{INT} = 'IGNORE';
211 local $SIG{QUIT} = 'IGNORE';
212 local $SIG{TERM} = 'IGNORE';
213 local $SIG{TSTP} = 'IGNORE';
214 local $SIG{PIPE} = 'IGNORE';
216 my $oldAutoCommit = $FS::UID::AutoCommit;
217 local $FS::UID::AutoCommit = 0;
220 foreach my $cust_pkg ( $self->cust_pkg ) {
221 $cust_pkg->contactnum('');
222 my $error = $cust_pkg->replace;
224 $dbh->rollback if $oldAutoCommit;
229 foreach my $object ( $self->contact_phone, $self->contact_email ) {
230 my $error = $object->delete;
232 $dbh->rollback if $oldAutoCommit;
237 my $error = $self->delete_password_history
238 || $self->SUPER::delete;
240 $dbh->rollback if $oldAutoCommit;
244 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
249 =item replace OLD_RECORD
251 Replaces the OLD_RECORD with this one in the database. If there is an error,
252 returns the error, otherwise returns false.
259 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
261 : $self->replace_old;
263 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
265 local $SIG{INT} = 'IGNORE';
266 local $SIG{QUIT} = 'IGNORE';
267 local $SIG{TERM} = 'IGNORE';
268 local $SIG{TSTP} = 'IGNORE';
269 local $SIG{PIPE} = 'IGNORE';
271 my $oldAutoCommit = $FS::UID::AutoCommit;
272 local $FS::UID::AutoCommit = 0;
275 my $error = $self->SUPER::replace($old);
276 if ( $old->_password ne $self->_password ) {
277 $error ||= $self->insert_password_history;
280 $dbh->rollback if $oldAutoCommit;
284 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
285 keys %{ $self->hashref } ) {
286 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
287 my $phonetypenum = $1;
289 my %cp = ( 'contactnum' => $self->contactnum,
290 'phonetypenum' => $phonetypenum,
292 my $contact_phone = qsearchs('contact_phone', \%cp);
294 my $pv = $self->get($pf);
297 #if new value is empty, delete old entry
299 if ($contact_phone) {
300 $error = $contact_phone->delete;
302 $dbh->rollback if $oldAutoCommit;
309 $contact_phone ||= new FS::contact_phone \%cp;
311 my %cpd = _parse_phonestring( $pv );
312 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
314 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
316 $error = $contact_phone->$method;
318 $dbh->rollback if $oldAutoCommit;
323 if ( defined($self->hashref->{'emailaddress'}) ) {
325 #ineffecient but whatever, how many email addresses can there be?
327 foreach my $contact_email ( $self->contact_email ) {
328 my $error = $contact_email->delete;
330 $dbh->rollback if $oldAutoCommit;
335 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
337 my $contact_email = new FS::contact_email {
338 'contactnum' => $self->contactnum,
339 'emailaddress' => $email,
341 $error = $contact_email->insert;
343 $dbh->rollback if $oldAutoCommit;
351 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
352 #warn " queueing fuzzyfiles update\n"
354 $error = $self->queue_fuzzyfiles_update;
356 $dbh->rollback if $oldAutoCommit;
357 return "updating fuzzy search cache: $error";
361 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
362 && ! $self->_password
367 my $error = $self->send_reset_email( queue=>1 );
369 $dbh->rollback if $oldAutoCommit;
374 if ( $self->get('password') ) {
375 my $error = $self->is_password_allowed($self->get('password'))
376 || $self->change_password($self->get('password'));
378 $dbh->rollback if $oldAutoCommit;
383 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
389 =item _parse_phonestring PHONENUMBER_STRING
391 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
392 with keys 'countrycode', 'phonenum' and 'extension'
394 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
398 sub _parse_phonestring {
401 my($countrycode, $extension) = ('1', '');
404 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
410 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
414 ( 'countrycode' => $countrycode,
415 'phonenum' => $value,
416 'extension' => $extension,
420 =item queue_fuzzyfiles_update
422 Used by insert & replace to update the fuzzy search cache
426 use FS::cust_main::Search;
427 sub queue_fuzzyfiles_update {
430 local $SIG{HUP} = 'IGNORE';
431 local $SIG{INT} = 'IGNORE';
432 local $SIG{QUIT} = 'IGNORE';
433 local $SIG{TERM} = 'IGNORE';
434 local $SIG{TSTP} = 'IGNORE';
435 local $SIG{PIPE} = 'IGNORE';
437 my $oldAutoCommit = $FS::UID::AutoCommit;
438 local $FS::UID::AutoCommit = 0;
441 foreach my $field ( 'first', 'last' ) {
442 my $queue = new FS::queue {
443 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
445 my @args = "contact.$field", $self->get($field);
446 my $error = $queue->insert( @args );
448 $dbh->rollback if $oldAutoCommit;
449 return "queueing job (transaction rolled back): $error";
453 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
460 Checks all fields to make sure this is a valid contact. If there is
461 an error, returns the error, otherwise returns false. Called by the insert
469 if ( $self->selfservice_access eq 'R' || $self->selfservice_access eq 'E' || $self->selfservice_access eq 'P' ) {
470 $self->selfservice_access('Y');
475 $self->ut_numbern('contactnum')
476 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
477 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
478 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
479 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
480 || $self->ut_namen('last')
481 || $self->ut_namen('first')
482 || $self->ut_textn('title')
483 || $self->ut_textn('comment')
484 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
485 || $self->ut_textn('_password')
486 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
487 || $self->ut_enum('disabled', [ '', 'Y' ])
489 return $error if $error;
491 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
492 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
494 return "One of first name, last name, or title must have a value"
495 if ! grep $self->$_(), qw( first last title);
502 Returns a formatted string representing this contact, including name, title and
509 my $data = $self->first. ' '. $self->last;
510 $data .= ', '. $self->title
512 $data .= ' ('. $self->comment. ')'
519 return '' unless $self->locationnum;
520 qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
525 return '' unless $self->classnum;
526 qsearchs('contact_class', { 'classnum' => $self->classnum } );
531 Returns a formatted string representing this contact, with just the name.
537 $self->first . ' ' . $self->last;
540 =item contact_classname
542 Returns the name of this contact's class (see L<FS::contact_class>).
546 sub contact_classname {
548 my $contact_class = $self->contact_class or return '';
549 $contact_class->classname;
554 qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
559 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
564 qsearchs('cust_main', { 'custnum' => $self->custnum } );
569 qsearch('cust_pkg', { 'contactnum' => $self->contactnum } );
572 =item by_selfservice_email EMAILADDRESS
574 Alternate search constructor (class method). Given an email address,
575 returns the contact for that address, or the empty string if no contact
576 has that email address.
580 sub by_selfservice_email {
581 my($class, $email) = @_;
583 my $contact_email = qsearchs({
584 'table' => 'contact_email',
585 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
586 'hashref' => { 'emailaddress' => $email, },
587 'extra_sql' => " AND selfservice_access = 'Y' ".
588 " AND ( disabled IS NULL OR disabled = '' )",
591 $contact_email->contact;
595 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
596 # and should maybe be libraried in some way for other password needs
598 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
600 sub authenticate_password {
601 my($self, $check_password) = @_;
603 if ( $self->_password_encoding eq 'bcrypt' ) {
605 my( $cost, $salt, $hash ) = split(',', $self->_password);
607 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
609 salt => de_base64($salt),
615 $hash eq $check_hash;
619 return 0 if $self->_password eq '';
621 $self->_password eq $check_password;
627 =item change_password NEW_PASSWORD
629 Changes the contact's selfservice access password to NEW_PASSWORD. This does
630 not check password policy rules (see C<is_password_allowed>) and will return
631 an error only if editing the record fails for some reason.
633 If NEW_PASSWORD is the same as the existing password, this does nothing.
637 sub change_password {
638 my($self, $new_password) = @_;
640 # do nothing if the password is unchanged
641 return if $self->authenticate_password($new_password);
643 $self->change_password_fields( $new_password );
649 sub change_password_fields {
650 my($self, $new_password) = @_;
652 $self->_password_encoding('bcrypt');
656 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
658 my $hash = bcrypt_hash( { key_nul => 1,
666 join(',', $cost, en_base64($salt), en_base64($hash) )
671 # end of false laziness w/FS/FS/Auth/internal.pm
674 #false laziness w/ClientAPI/MyAccount/reset_passwd
675 use Digest::SHA qw(sha512_hex);
677 use FS::ClientAPI_SessionCache;
678 sub send_reset_email {
679 my( $self, %opt ) = @_;
681 my @contact_email = $self->contact_email or return '';
683 my $reset_session = {
684 'contactnum' => $self->contactnum,
685 'svcnum' => $opt{'svcnum'},
689 my $conf = new FS::Conf;
691 ($conf->config('selfservice-password_reset_hours') || 24 ). ' hours';
693 my $reset_session_id;
695 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
696 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
699 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
703 my $cust_main = $self->cust_main
704 or die "no customer"; #reset a password for a prospect contact? someday
706 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
707 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
708 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
709 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
711 'to' => join(',', map $_->emailaddress, @contact_email ),
712 'cust_main' => $cust_main,
714 'substitutions' => { 'session_id' => $reset_session_id }
717 if ( $opt{'queue'} ) { #or should queueing just be the default?
719 my $queue = new FS::queue {
720 'job' => 'FS::Misc::process_send_email',
721 'custnum' => $cust_main->custnum,
723 $queue->insert( $msg_template->prepare( %msg_template ) );
727 $msg_template->send( %msg_template );
733 use vars qw( $myaccount_cache );
734 sub myaccount_cache {
736 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
737 'namespace' => 'FS::ClientAPI::MyAccount',
741 =item cgi_contact_fields
743 Returns a list reference containing the set of contact fields used in the web
744 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
745 and locationnum, as well as password fields, but including fields for
746 contact_email and contact_phone records.)
750 sub cgi_contact_fields {
753 my @contact_fields = qw(
754 classnum first last title comment emailaddress selfservice_access
755 invoice_dest password
758 push @contact_fields, 'phonetypenum'. $_->phonetypenum
759 foreach qsearch({table=>'phone_type', order_by=>'weight'});
773 L<FS::Record>, schema.html from the base documentation.