2 use base qw( FS::Record );
5 use vars qw( $skip_fuzzyfiles );
7 use Scalar::Util qw( blessed );
8 use FS::Record qw( qsearch qsearchs dbh );
10 use FS::contact_email;
12 use FS::phone_type; #for cgi_contact_fields
14 use FS::prospect_contact;
20 FS::contact - Object methods for contact records
26 $record = new FS::contact \%hash;
27 $record = new FS::contact { 'column' => 'value' };
29 $error = $record->insert;
31 $error = $new_record->replace($old_record);
33 $error = $record->delete;
35 $error = $record->check;
39 An FS::contact object represents an specific contact person for a prospect or
40 customer. FS::contact inherits from FS::Record. The following fields are
77 =item selfservice_access
83 =item _password_encoding
100 Creates a new contact. To add the contact to the database, see L<"insert">.
102 Note that this stores the hash reference, not a distinct copy of the hash it
103 points to. You can ask the object for a copy with the I<hash> method.
107 sub table { 'contact'; }
111 Adds this record to the database. If there is an error, returns the error,
112 otherwise returns false.
119 local $SIG{INT} = 'IGNORE';
120 local $SIG{QUIT} = 'IGNORE';
121 local $SIG{TERM} = 'IGNORE';
122 local $SIG{TSTP} = 'IGNORE';
123 local $SIG{PIPE} = 'IGNORE';
125 my $oldAutoCommit = $FS::UID::AutoCommit;
126 local $FS::UID::AutoCommit = 0;
129 #save off and blank values that move to cust_contact / prospect_contact now
130 my $prospectnum = $self->prospectnum;
131 $self->prospectnum('');
132 my $custnum = $self->custnum;
136 for (qw( classnum comment selfservice_access )) {
137 $link_hash{$_} = $self->get($_);
141 #look for an existing contact with this email address
142 my $existing_contact = '';
143 if ( $self->get('emailaddress') =~ /\S/ ) {
145 my %existing_contact = ();
147 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
149 my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
152 my $contact = $contact_email->contact;
153 $existing_contact{ $contact->contactnum } = $contact;
157 if ( scalar( keys %existing_contact ) > 1 ) {
158 $dbh->rollback if $oldAutoCommit;
159 return 'Multiple email addresses specified '.
160 ' that already belong to separate contacts';
161 } elsif ( scalar( keys %existing_contact ) ) {
162 ($existing_contact) = values %existing_contact;
167 if ( $existing_contact ) {
169 $self->$_($existing_contact->$_())
170 for qw( contactnum _password _password_encoding );
171 $self->SUPER::replace($existing_contact);
175 my $error = $self->SUPER::insert;
177 $dbh->rollback if $oldAutoCommit;
183 my $cust_contact = '';
185 my %hash = ( 'contactnum' => $self->contactnum,
186 'custnum' => $custnum,
188 $cust_contact = qsearchs('cust_contact', \%hash )
189 || new FS::cust_contact { %hash, %link_hash };
190 my $error = $cust_contact->custcontactnum ? $cust_contact->replace
191 : $cust_contact->insert;
193 $dbh->rollback if $oldAutoCommit;
198 if ( $prospectnum ) {
199 my %hash = ( 'contactnum' => $self->contactnum,
200 'prospectnum' => $prospectnum,
202 my $prospect_contact = qsearchs('prospect_contact', \%hash )
203 || new FS::prospect_contact { %hash, %link_hash };
205 $prospect_contact->prospectcontactnum ? $prospect_contact->replace
206 : $prospect_contact->insert;
208 $dbh->rollback if $oldAutoCommit;
213 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
214 keys %{ $self->hashref } ) {
215 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
216 my $phonetypenum = $1;
218 my %hash = ( 'contactnum' => $self->contactnum,
219 'phonetypenum' => $phonetypenum,
222 qsearchs('contact_phone', \%hash)
223 || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) };
224 my $error = $contact_phone->contactphonenum ? $contact_phone->replace
225 : $contact_phone->insert;
227 $dbh->rollback if $oldAutoCommit;
232 if ( $self->get('emailaddress') =~ /\S/ ) {
234 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
236 'contactnum' => $self->contactnum,
237 'emailaddress' => $email,
239 unless ( qsearchs('contact_email', \%hash) ) {
240 my $contact_email = new FS::contact_email \%hash;
241 my $error = $contact_email->insert;
243 $dbh->rollback if $oldAutoCommit;
251 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
252 #warn " queueing fuzzyfiles update\n"
254 my $error = $self->queue_fuzzyfiles_update;
256 $dbh->rollback if $oldAutoCommit;
257 return "updating fuzzy search cache: $error";
261 if ( $link_hash{'selfservice_access'} eq 'R'
262 or ( $link_hash{'selfservice_access'}
264 && ! length($self->_password)
268 my $error = $self->send_reset_email( queue=>1 );
270 $dbh->rollback if $oldAutoCommit;
275 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
283 Delete this record from the database.
290 local $SIG{HUP} = 'IGNORE';
291 local $SIG{INT} = 'IGNORE';
292 local $SIG{QUIT} = 'IGNORE';
293 local $SIG{TERM} = 'IGNORE';
294 local $SIG{TSTP} = 'IGNORE';
295 local $SIG{PIPE} = 'IGNORE';
297 my $oldAutoCommit = $FS::UID::AutoCommit;
298 local $FS::UID::AutoCommit = 0;
301 #got a prospetnum or custnum? delete the prospect_contact or cust_contact link
303 if ( $self->prospectnum ) {
304 my $prospect_contact = qsearchs('prospect_contact', {
305 'contactnum' => $self->contactnum,
306 'prospectnum' => $self->prospectnum,
308 my $error = $prospect_contact->delete;
310 $dbh->rollback if $oldAutoCommit;
315 if ( $self->custnum ) {
316 my $cust_contact = qsearchs('cust_contact', {
317 'contactnum' => $self->contactnum,
318 'custnum' => $self->custnum,
320 my $error = $cust_contact->delete;
322 $dbh->rollback if $oldAutoCommit;
327 # then, proceed with deletion only if the contact isn't attached to any other
328 # prospects or customers
330 #inefficient, but how many prospects/customers can a single contact be
331 # attached too? (and is removing them from one a common operation?)
332 if ( $self->prospect_contact || $self->cust_contact ) {
333 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
337 #proceed with deletion
339 foreach my $cust_pkg ( $self->cust_pkg ) {
340 $cust_pkg->contactnum('');
341 my $error = $cust_pkg->replace;
343 $dbh->rollback if $oldAutoCommit;
348 foreach my $object ( $self->contact_phone, $self->contact_email ) {
349 my $error = $object->delete;
351 $dbh->rollback if $oldAutoCommit;
356 my $error = $self->SUPER::delete;
358 $dbh->rollback if $oldAutoCommit;
362 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
367 =item replace OLD_RECORD
369 Replaces the OLD_RECORD with this one in the database. If there is an error,
370 returns the error, otherwise returns false.
377 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
379 : $self->replace_old;
381 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
383 local $SIG{INT} = 'IGNORE';
384 local $SIG{QUIT} = 'IGNORE';
385 local $SIG{TERM} = 'IGNORE';
386 local $SIG{TSTP} = 'IGNORE';
387 local $SIG{PIPE} = 'IGNORE';
389 my $oldAutoCommit = $FS::UID::AutoCommit;
390 local $FS::UID::AutoCommit = 0;
393 #save off and blank values that move to cust_contact / prospect_contact now
394 my $prospectnum = $self->prospectnum;
395 $self->prospectnum('');
396 my $custnum = $self->custnum;
400 for (qw( classnum comment selfservice_access )) {
401 $link_hash{$_} = $self->get($_);
405 my $error = $self->SUPER::replace($old);
407 $dbh->rollback if $oldAutoCommit;
411 my $cust_contact = '';
413 my %hash = ( 'contactnum' => $self->contactnum,
414 'custnum' => $custnum,
417 if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
418 $cust_contact->$_($link_hash{$_}) for keys %link_hash;
419 $error = $cust_contact->replace;
421 $cust_contact = new FS::cust_contact { %hash, %link_hash };
422 $error = $cust_contact->insert;
425 $dbh->rollback if $oldAutoCommit;
430 if ( $prospectnum ) {
431 my %hash = ( 'contactnum' => $self->contactnum,
432 'prospectnum' => $prospectnum,
435 if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) {
436 $prospect_contact->$_($link_hash{$_}) for keys %link_hash;
437 $error = $prospect_contact->replace;
439 my $prospect_contact = new FS::prospect_contact { %hash, %link_hash };
440 $error = $prospect_contact->insert;
443 $dbh->rollback if $oldAutoCommit;
448 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
449 keys %{ $self->hashref } ) {
450 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
451 my $phonetypenum = $1;
453 my %cp = ( 'contactnum' => $self->contactnum,
454 'phonetypenum' => $phonetypenum,
456 my $contact_phone = qsearchs('contact_phone', \%cp);
458 my $pv = $self->get($pf);
461 #if new value is empty, delete old entry
463 if ($contact_phone) {
464 $error = $contact_phone->delete;
466 $dbh->rollback if $oldAutoCommit;
473 $contact_phone ||= new FS::contact_phone \%cp;
475 my %cpd = _parse_phonestring( $pv );
476 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
478 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
480 $error = $contact_phone->$method;
482 $dbh->rollback if $oldAutoCommit;
487 if ( defined($self->hashref->{'emailaddress'}) ) {
489 #ineffecient but whatever, how many email addresses can there be?
491 foreach my $contact_email ( $self->contact_email ) {
492 my $error = $contact_email->delete;
494 $dbh->rollback if $oldAutoCommit;
499 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
501 my $contact_email = new FS::contact_email {
502 'contactnum' => $self->contactnum,
503 'emailaddress' => $email,
505 $error = $contact_email->insert;
507 $dbh->rollback if $oldAutoCommit;
515 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
516 #warn " queueing fuzzyfiles update\n"
518 $error = $self->queue_fuzzyfiles_update;
520 $dbh->rollback if $oldAutoCommit;
521 return "updating fuzzy search cache: $error";
525 if ( $cust_contact and (
526 ( $cust_contact->selfservice_access eq ''
527 && $link_hash{selfservice_access}
528 && ! length($self->_password)
530 || $cust_contact->_resend()
534 my $error = $self->send_reset_email( queue=>1 );
536 $dbh->rollback if $oldAutoCommit;
541 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
547 =item _parse_phonestring PHONENUMBER_STRING
549 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
550 with keys 'countrycode', 'phonenum' and 'extension'
552 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
556 sub _parse_phonestring {
559 my($countrycode, $extension) = ('1', '');
562 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
568 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
572 ( 'countrycode' => $countrycode,
573 'phonenum' => $value,
574 'extension' => $extension,
578 =item queue_fuzzyfiles_update
580 Used by insert & replace to update the fuzzy search cache
584 use FS::cust_main::Search;
585 sub queue_fuzzyfiles_update {
588 local $SIG{HUP} = 'IGNORE';
589 local $SIG{INT} = 'IGNORE';
590 local $SIG{QUIT} = 'IGNORE';
591 local $SIG{TERM} = 'IGNORE';
592 local $SIG{TSTP} = 'IGNORE';
593 local $SIG{PIPE} = 'IGNORE';
595 my $oldAutoCommit = $FS::UID::AutoCommit;
596 local $FS::UID::AutoCommit = 0;
599 foreach my $field ( 'first', 'last' ) {
600 my $queue = new FS::queue {
601 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
603 my @args = "contact.$field", $self->get($field);
604 my $error = $queue->insert( @args );
606 $dbh->rollback if $oldAutoCommit;
607 return "queueing job (transaction rolled back): $error";
611 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
618 Checks all fields to make sure this is a valid contact. If there is
619 an error, returns the error, otherwise returns false. Called by the insert
627 if ( $self->selfservice_access eq 'R' ) {
628 $self->selfservice_access('Y');
633 $self->ut_numbern('contactnum')
634 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
635 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
636 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
637 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
638 || $self->ut_namen('last')
639 || $self->ut_namen('first')
640 || $self->ut_textn('title')
641 || $self->ut_textn('comment')
642 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
643 || $self->ut_textn('_password')
644 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
645 || $self->ut_enum('disabled', [ '', 'Y' ])
647 return $error if $error;
649 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
651 return "One of first name, last name, or title must have a value"
652 if ! grep $self->$_(), qw( first last title);
659 Returns a formatted string representing this contact, including name, title and
666 my $data = $self->first. ' '. $self->last;
667 $data .= ', '. $self->title
669 $data .= ' ('. $self->comment. ')'
676 Returns a formatted string representing this contact, with just the name.
682 $self->first . ' ' . $self->last;
685 #=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
687 #Returns the name of this contact's class for the specified prospect or
688 #customer (see L<FS::prospect_contact>, L<FS::cust_contact> and
689 #L<FS::contact_class>).
693 #sub contact_classname {
694 # my( $self, $prospect_or_cust ) = @_;
697 # if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) {
698 # $link = qsearchs('prospect_contact', {
699 # 'contactnum' => $self->contactnum,
700 # 'prospectnum' => $prospect_or_cust->prospectnum,
702 # } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) {
703 # $link = qsearchs('cust_contact', {
704 # 'contactnum' => $self->contactnum,
705 # 'custnum' => $prospect_or_cust->custnum,
708 # croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
711 # my $contact_class = $link->contact_class or return '';
712 # $contact_class->classname;
715 =item by_selfservice_email EMAILADDRESS
717 Alternate search constructor (class method). Given an email address,
718 returns the contact for that address, or the empty string if no contact
719 has that email address.
723 sub by_selfservice_email {
724 my($class, $email) = @_;
726 my $contact_email = qsearchs({
727 'table' => 'contact_email',
728 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
729 'hashref' => { 'emailaddress' => $email, },
730 'extra_sql' => " AND ( disabled IS NULL OR disabled = '' )",
733 $contact_email->contact;
737 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
738 # and should maybe be libraried in some way for other password needs
740 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
742 sub authenticate_password {
743 my($self, $check_password) = @_;
745 if ( $self->_password_encoding eq 'bcrypt' ) {
747 my( $cost, $salt, $hash ) = split(',', $self->_password);
749 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
751 salt => de_base64($salt),
757 $hash eq $check_hash;
761 return 0 if $self->_password eq '';
763 $self->_password eq $check_password;
769 sub change_password {
770 my($self, $new_password) = @_;
772 $self->change_password_fields( $new_password );
778 sub change_password_fields {
779 my($self, $new_password) = @_;
781 $self->_password_encoding('bcrypt');
785 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
787 my $hash = bcrypt_hash( { key_nul => 1,
795 join(',', $cost, en_base64($salt), en_base64($hash) )
800 # end of false laziness w/FS/FS/Auth/internal.pm
803 #false laziness w/ClientAPI/MyAccount/reset_passwd
804 use Digest::SHA qw(sha512_hex);
806 use FS::ClientAPI_SessionCache;
807 sub send_reset_email {
808 my( $self, %opt ) = @_;
810 my @contact_email = $self->contact_email or return '';
812 my $reset_session = {
813 'contactnum' => $self->contactnum,
814 'svcnum' => $opt{'svcnum'},
817 my $timeout = '24 hours'; #?
819 my $reset_session_id;
821 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
822 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
825 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
829 my $conf = new FS::Conf;
832 my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
833 $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
835 my $agentnum = $cust_main ? $cust_main->agentnum : '';
836 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
837 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
838 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
839 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
841 'to' => join(',', map $_->emailaddress, @contact_email ),
842 'cust_main' => $cust_main,
844 'substitutions' => { 'session_id' => $reset_session_id }
847 if ( $opt{'queue'} ) { #or should queueing just be the default?
849 my $queue = new FS::queue {
850 'job' => 'FS::Misc::process_send_email',
851 'custnum' => $cust_main ? $cust_main->custnum : '',
853 $queue->insert( $msg_template->prepare( %msg_template ) );
857 $msg_template->send( %msg_template );
863 use vars qw( $myaccount_cache );
864 sub myaccount_cache {
866 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
867 'namespace' => 'FS::ClientAPI::MyAccount',
871 =item cgi_contact_fields
873 Returns a list reference containing the set of contact fields used in the web
874 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
875 and locationnum, as well as password fields, but including fields for
876 contact_email and contact_phone records.)
880 sub cgi_contact_fields {
883 my @contact_fields = qw(
884 classnum first last title comment emailaddress selfservice_access
887 push @contact_fields, 'phonetypenum'. $_->phonetypenum
888 foreach qsearch({table=>'phone_type', order_by=>'weight'});
894 use FS::upgrade_journal;
895 sub _upgrade_data { #class method
896 my ($class, %opts) = @_;
898 unless ( FS::upgrade_journal->is_done('contact__DUPEMAIL') ) {
900 foreach my $contact (qsearch('contact', {})) {
901 my $error = $contact->replace;
902 die $error if $error;
905 FS::upgrade_journal->set_done('contact__DUPEMAIL');
916 L<FS::Record>, schema.html from the base documentation.