2 use base qw( FS::Record );
5 use vars qw( $skip_fuzzyfiles );
6 use Scalar::Util qw( blessed );
7 use FS::Record qw( qsearch qsearchs dbh );
10 use FS::contact_class;
11 use FS::cust_location;
12 use FS::contact_phone;
13 use FS::contact_email;
16 use FS::phone_type; #for cgi_contact_fields
22 FS::contact - Object methods for contact records
28 $record = new FS::contact \%hash;
29 $record = new FS::contact { 'column' => 'value' };
31 $error = $record->insert;
33 $error = $new_record->replace($old_record);
35 $error = $record->delete;
37 $error = $record->check;
41 An FS::contact object represents an specific contact person for a prospect or
42 customer. FS::contact inherits from FS::Record. The following fields are
79 =item selfservice_access
85 =item _password_encoding
102 Creates a new contact. To add the contact to the database, see L<"insert">.
104 Note that this stores the hash reference, not a distinct copy of the hash it
105 points to. You can ask the object for a copy with the I<hash> method.
109 sub table { 'contact'; }
113 Adds this record to the database. If there is an error, returns the error,
114 otherwise returns false.
121 local $SIG{INT} = 'IGNORE';
122 local $SIG{QUIT} = 'IGNORE';
123 local $SIG{TERM} = 'IGNORE';
124 local $SIG{TSTP} = 'IGNORE';
125 local $SIG{PIPE} = 'IGNORE';
127 my $oldAutoCommit = $FS::UID::AutoCommit;
128 local $FS::UID::AutoCommit = 0;
131 my $error = $self->SUPER::insert;
133 $dbh->rollback if $oldAutoCommit;
137 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
138 keys %{ $self->hashref } ) {
139 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
140 my $phonetypenum = $1;
142 my $contact_phone = new FS::contact_phone {
143 'contactnum' => $self->contactnum,
144 'phonetypenum' => $phonetypenum,
145 _parse_phonestring( $self->get($pf) ),
147 $error = $contact_phone->insert;
149 $dbh->rollback if $oldAutoCommit;
154 if ( $self->get('emailaddress') =~ /\S/ ) {
156 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
158 my $contact_email = new FS::contact_email {
159 'contactnum' => $self->contactnum,
160 'emailaddress' => $email,
162 $error = $contact_email->insert;
164 $dbh->rollback if $oldAutoCommit;
172 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
173 #warn " queueing fuzzyfiles update\n"
175 $error = $self->queue_fuzzyfiles_update;
177 $dbh->rollback if $oldAutoCommit;
178 return "updating fuzzy search cache: $error";
182 if ( $self->selfservice_access ) {
183 my $error = $self->send_reset_email( queue=>1 );
185 $dbh->rollback if $oldAutoCommit;
190 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
198 Delete this record from the database.
205 local $SIG{HUP} = 'IGNORE';
206 local $SIG{INT} = 'IGNORE';
207 local $SIG{QUIT} = 'IGNORE';
208 local $SIG{TERM} = 'IGNORE';
209 local $SIG{TSTP} = 'IGNORE';
210 local $SIG{PIPE} = 'IGNORE';
212 my $oldAutoCommit = $FS::UID::AutoCommit;
213 local $FS::UID::AutoCommit = 0;
216 foreach my $cust_pkg ( $self->cust_pkg ) {
217 $cust_pkg->contactnum('');
218 my $error = $cust_pkg->replace;
220 $dbh->rollback if $oldAutoCommit;
225 foreach my $object ( $self->contact_phone, $self->contact_email ) {
226 my $error = $object->delete;
228 $dbh->rollback if $oldAutoCommit;
233 my $error = $self->SUPER::delete;
235 $dbh->rollback if $oldAutoCommit;
239 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
244 =item replace OLD_RECORD
246 Replaces the OLD_RECORD with this one in the database. If there is an error,
247 returns the error, otherwise returns false.
254 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
256 : $self->replace_old;
258 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
260 local $SIG{INT} = 'IGNORE';
261 local $SIG{QUIT} = 'IGNORE';
262 local $SIG{TERM} = 'IGNORE';
263 local $SIG{TSTP} = 'IGNORE';
264 local $SIG{PIPE} = 'IGNORE';
266 my $oldAutoCommit = $FS::UID::AutoCommit;
267 local $FS::UID::AutoCommit = 0;
270 my $error = $self->SUPER::replace($old);
272 $dbh->rollback if $oldAutoCommit;
276 foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
277 keys %{ $self->hashref } ) {
278 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
279 my $phonetypenum = $1;
281 my %cp = ( 'contactnum' => $self->contactnum,
282 'phonetypenum' => $phonetypenum,
284 my $contact_phone = qsearchs('contact_phone', \%cp);
286 # if new value is empty, delete old entry
287 if (!$self->get($pf)) {
288 if ($contact_phone) {
289 $error = $contact_phone->delete;
291 $dbh->rollback if $oldAutoCommit;
298 $contact_phone ||= new FS::contact_phone \%cp;
300 my %cpd = _parse_phonestring( $self->get($pf) );
301 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
303 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
305 $error = $contact_phone->$method;
307 $dbh->rollback if $oldAutoCommit;
312 if ( defined($self->hashref->{'emailaddress'}) ) {
314 #ineffecient but whatever, how many email addresses can there be?
316 foreach my $contact_email ( $self->contact_email ) {
317 my $error = $contact_email->delete;
319 $dbh->rollback if $oldAutoCommit;
324 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
326 my $contact_email = new FS::contact_email {
327 'contactnum' => $self->contactnum,
328 'emailaddress' => $email,
330 $error = $contact_email->insert;
332 $dbh->rollback if $oldAutoCommit;
340 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
341 #warn " queueing fuzzyfiles update\n"
343 $error = $self->queue_fuzzyfiles_update;
345 $dbh->rollback if $oldAutoCommit;
346 return "updating fuzzy search cache: $error";
350 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
351 && ! $self->_password
356 my $error = $self->send_reset_email( queue=>1 );
358 $dbh->rollback if $oldAutoCommit;
363 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
369 =item _parse_phonestring PHONENUMBER_STRING
371 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
372 with keys 'countrycode', 'phonenum' and 'extension'
374 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
378 sub _parse_phonestring {
381 my($countrycode, $extension) = ('1', '');
384 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
390 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
394 ( 'countrycode' => $countrycode,
395 'phonenum' => $value,
396 'extension' => $extension,
400 =item queue_fuzzyfiles_update
402 Used by insert & replace to update the fuzzy search cache
406 use FS::cust_main::Search;
407 sub queue_fuzzyfiles_update {
410 local $SIG{HUP} = 'IGNORE';
411 local $SIG{INT} = 'IGNORE';
412 local $SIG{QUIT} = 'IGNORE';
413 local $SIG{TERM} = 'IGNORE';
414 local $SIG{TSTP} = 'IGNORE';
415 local $SIG{PIPE} = 'IGNORE';
417 my $oldAutoCommit = $FS::UID::AutoCommit;
418 local $FS::UID::AutoCommit = 0;
421 foreach my $field ( 'first', 'last' ) {
422 my $queue = new FS::queue {
423 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
425 my @args = "contact.$field", $self->get($field);
426 my $error = $queue->insert( @args );
428 $dbh->rollback if $oldAutoCommit;
429 return "queueing job (transaction rolled back): $error";
433 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
440 Checks all fields to make sure this is a valid contact. If there is
441 an error, returns the error, otherwise returns false. Called by the insert
449 if ( $self->selfservice_access eq 'R' ) {
450 $self->selfservice_access('Y');
455 $self->ut_numbern('contactnum')
456 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
457 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
458 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
459 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
460 || $self->ut_namen('last')
461 || $self->ut_namen('first')
462 || $self->ut_textn('title')
463 || $self->ut_textn('comment')
464 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
465 || $self->ut_textn('_password')
466 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
467 || $self->ut_enum('disabled', [ '', 'Y' ])
469 return $error if $error;
471 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
472 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
474 return "One of first name, last name, or title must have a value"
475 if ! grep $self->$_(), qw( first last title);
482 Returns a formatted string representing this contact, including name, title and
489 my $data = $self->first. ' '. $self->last;
490 $data .= ', '. $self->title
492 $data .= ' ('. $self->comment. ')'
499 return '' unless $self->locationnum;
500 qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
505 return '' unless $self->classnum;
506 qsearchs('contact_class', { 'classnum' => $self->classnum } );
511 Returns a formatted string representing this contact, with just the name.
517 $self->first . ' ' . $self->last;
520 =item contact_classname
522 Returns the name of this contact's class (see L<FS::contact_class>).
526 sub contact_classname {
528 my $contact_class = $self->contact_class or return '';
529 $contact_class->classname;
534 qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
539 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
544 qsearchs('cust_main', { 'custnum' => $self->custnum } );
549 qsearch('cust_pkg', { 'contactnum' => $self->contactnum } );
552 =item by_selfservice_email EMAILADDRESS
554 Alternate search constructor (class method). Given an email address,
555 returns the contact for that address, or the empty string if no contact
556 has that email address.
560 sub by_selfservice_email {
561 my($class, $email) = @_;
563 my $contact_email = qsearchs({
564 'table' => 'contact_email',
565 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
566 'hashref' => { 'emailaddress' => $email, },
567 'extra_sql' => " AND selfservice_access = 'Y' ".
568 " AND ( disabled IS NULL OR disabled = '' )",
571 $contact_email->contact;
575 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
576 # and should maybe be libraried in some way for other password needs
578 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
580 sub authenticate_password {
581 my($self, $check_password) = @_;
583 if ( $self->_password_encoding eq 'bcrypt' ) {
585 my( $cost, $salt, $hash ) = split(',', $self->_password);
587 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
589 salt => de_base64($salt),
595 $hash eq $check_hash;
599 return 0 if $self->_password eq '';
601 $self->_password eq $check_password;
607 sub change_password {
608 my($self, $new_password) = @_;
610 $self->change_password_fields( $new_password );
616 sub change_password_fields {
617 my($self, $new_password) = @_;
619 $self->_password_encoding('bcrypt');
623 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
625 my $hash = bcrypt_hash( { key_nul => 1,
633 join(',', $cost, en_base64($salt), en_base64($hash) )
638 # end of false laziness w/FS/FS/Auth/internal.pm
641 #false laziness w/ClientAPI/MyAccount/reset_passwd
642 use Digest::SHA qw(sha512_hex);
644 use FS::ClientAPI_SessionCache;
645 sub send_reset_email {
646 my( $self, %opt ) = @_;
648 my @contact_email = $self->contact_email or return '';
650 my $reset_session = {
651 'contactnum' => $self->contactnum,
652 'svcnum' => $opt{'svcnum'},
655 my $timeout = '24 hours'; #?
657 my $reset_session_id;
659 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
660 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
663 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
667 my $conf = new FS::Conf;
669 my $cust_main = $self->cust_main
670 or die "no customer"; #reset a password for a prospect contact? someday
672 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
673 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
674 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
675 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
677 'to' => join(',', map $_->emailaddress, @contact_email ),
678 'cust_main' => $cust_main,
680 'substitutions' => { 'session_id' => $reset_session_id }
683 if ( $opt{'queue'} ) { #or should queueing just be the default?
685 my $queue = new FS::queue {
686 'job' => 'FS::Misc::process_send_email',
687 'custnum' => $cust_main->custnum,
689 $queue->insert( $msg_template->prepare( %msg_template ) );
693 $msg_template->send( %msg_template );
699 use vars qw( $myaccount_cache );
700 sub myaccount_cache {
702 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
703 'namespace' => 'FS::ClientAPI::MyAccount',
707 =item cgi_contact_fields
709 Returns a list reference containing the set of contact fields used in the web
710 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
711 and locationnum, as well as password fields, but including fields for
712 contact_email and contact_phone records.)
716 sub cgi_contact_fields {
719 my @contact_fields = qw(
720 classnum first last title comment emailaddress selfservice_access
723 push @contact_fields, 'phonetypenum'. $_->phonetypenum
724 foreach qsearch({table=>'phone_type', order_by=>'weight'});
738 L<FS::Record>, schema.html from the base documentation.