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 && ! length($self->_password) ) {
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 my $pv = $self->get($pf);
289 #if new value is empty, delete old entry
291 if ($contact_phone) {
292 $error = $contact_phone->delete;
294 $dbh->rollback if $oldAutoCommit;
301 $contact_phone ||= new FS::contact_phone \%cp;
303 my %cpd = _parse_phonestring( $pv );
304 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
306 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
308 $error = $contact_phone->$method;
310 $dbh->rollback if $oldAutoCommit;
315 if ( defined($self->hashref->{'emailaddress'}) ) {
317 #ineffecient but whatever, how many email addresses can there be?
319 foreach my $contact_email ( $self->contact_email ) {
320 my $error = $contact_email->delete;
322 $dbh->rollback if $oldAutoCommit;
327 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
329 my $contact_email = new FS::contact_email {
330 'contactnum' => $self->contactnum,
331 'emailaddress' => $email,
333 $error = $contact_email->insert;
335 $dbh->rollback if $oldAutoCommit;
343 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
344 #warn " queueing fuzzyfiles update\n"
346 $error = $self->queue_fuzzyfiles_update;
348 $dbh->rollback if $oldAutoCommit;
349 return "updating fuzzy search cache: $error";
353 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
354 && ! $self->_password
359 my $error = $self->send_reset_email( queue=>1 );
361 $dbh->rollback if $oldAutoCommit;
366 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
372 =item _parse_phonestring PHONENUMBER_STRING
374 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
375 with keys 'countrycode', 'phonenum' and 'extension'
377 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
381 sub _parse_phonestring {
384 my($countrycode, $extension) = ('1', '');
387 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
393 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
397 ( 'countrycode' => $countrycode,
398 'phonenum' => $value,
399 'extension' => $extension,
403 =item queue_fuzzyfiles_update
405 Used by insert & replace to update the fuzzy search cache
409 use FS::cust_main::Search;
410 sub queue_fuzzyfiles_update {
413 local $SIG{HUP} = 'IGNORE';
414 local $SIG{INT} = 'IGNORE';
415 local $SIG{QUIT} = 'IGNORE';
416 local $SIG{TERM} = 'IGNORE';
417 local $SIG{TSTP} = 'IGNORE';
418 local $SIG{PIPE} = 'IGNORE';
420 my $oldAutoCommit = $FS::UID::AutoCommit;
421 local $FS::UID::AutoCommit = 0;
424 foreach my $field ( 'first', 'last' ) {
425 my $queue = new FS::queue {
426 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
428 my @args = "contact.$field", $self->get($field);
429 my $error = $queue->insert( @args );
431 $dbh->rollback if $oldAutoCommit;
432 return "queueing job (transaction rolled back): $error";
436 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
443 Checks all fields to make sure this is a valid contact. If there is
444 an error, returns the error, otherwise returns false. Called by the insert
452 if ( $self->selfservice_access eq 'R' ) {
453 $self->selfservice_access('Y');
458 $self->ut_numbern('contactnum')
459 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
460 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
461 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
462 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
463 || $self->ut_namen('last')
464 || $self->ut_namen('first')
465 || $self->ut_textn('title')
466 || $self->ut_textn('comment')
467 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
468 || $self->ut_textn('_password')
469 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
470 || $self->ut_enum('disabled', [ '', 'Y' ])
472 return $error if $error;
474 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
475 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
477 return "One of first name, last name, or title must have a value"
478 if ! grep $self->$_(), qw( first last title);
485 Returns a formatted string representing this contact, including name, title and
492 my $data = $self->first. ' '. $self->last;
493 $data .= ', '. $self->title
495 $data .= ' ('. $self->comment. ')'
502 return '' unless $self->locationnum;
503 qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
508 return '' unless $self->classnum;
509 qsearchs('contact_class', { 'classnum' => $self->classnum } );
514 Returns a formatted string representing this contact, with just the name.
520 $self->first . ' ' . $self->last;
523 =item contact_classname
525 Returns the name of this contact's class (see L<FS::contact_class>).
529 sub contact_classname {
531 my $contact_class = $self->contact_class or return '';
532 $contact_class->classname;
537 qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
542 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
547 qsearchs('cust_main', { 'custnum' => $self->custnum } );
552 qsearch('cust_pkg', { 'contactnum' => $self->contactnum } );
555 =item by_selfservice_email EMAILADDRESS
557 Alternate search constructor (class method). Given an email address,
558 returns the contact for that address, or the empty string if no contact
559 has that email address.
563 sub by_selfservice_email {
564 my($class, $email) = @_;
566 my $contact_email = qsearchs({
567 'table' => 'contact_email',
568 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
569 'hashref' => { 'emailaddress' => $email, },
570 'extra_sql' => " AND selfservice_access = 'Y' ".
571 " AND ( disabled IS NULL OR disabled = '' )",
574 $contact_email->contact;
578 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
579 # and should maybe be libraried in some way for other password needs
581 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
583 sub authenticate_password {
584 my($self, $check_password) = @_;
586 if ( $self->_password_encoding eq 'bcrypt' ) {
588 my( $cost, $salt, $hash ) = split(',', $self->_password);
590 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
592 salt => de_base64($salt),
598 $hash eq $check_hash;
602 return 0 if $self->_password eq '';
604 $self->_password eq $check_password;
610 sub change_password {
611 my($self, $new_password) = @_;
613 $self->change_password_fields( $new_password );
619 sub change_password_fields {
620 my($self, $new_password) = @_;
622 $self->_password_encoding('bcrypt');
626 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
628 my $hash = bcrypt_hash( { key_nul => 1,
636 join(',', $cost, en_base64($salt), en_base64($hash) )
641 # end of false laziness w/FS/FS/Auth/internal.pm
644 #false laziness w/ClientAPI/MyAccount/reset_passwd
645 use Digest::SHA qw(sha512_hex);
647 use FS::ClientAPI_SessionCache;
648 sub send_reset_email {
649 my( $self, %opt ) = @_;
651 my @contact_email = $self->contact_email or return '';
653 my $reset_session = {
654 'contactnum' => $self->contactnum,
655 'svcnum' => $opt{'svcnum'},
658 my $timeout = '24 hours'; #?
660 my $reset_session_id;
662 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
663 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
666 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
670 my $conf = new FS::Conf;
672 my $cust_main = $self->cust_main
673 or die "no customer"; #reset a password for a prospect contact? someday
675 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
676 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
677 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
678 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
680 'to' => join(',', map $_->emailaddress, @contact_email ),
681 'cust_main' => $cust_main,
683 'substitutions' => { 'session_id' => $reset_session_id }
686 if ( $opt{'queue'} ) { #or should queueing just be the default?
688 my $queue = new FS::queue {
689 'job' => 'FS::Misc::process_send_email',
690 'custnum' => $cust_main->custnum,
692 $queue->insert( $msg_template->prepare( %msg_template ) );
696 $msg_template->send( %msg_template );
702 use vars qw( $myaccount_cache );
703 sub myaccount_cache {
705 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
706 'namespace' => 'FS::ClientAPI::MyAccount',
710 =item cgi_contact_fields
712 Returns a list reference containing the set of contact fields used in the web
713 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
714 and locationnum, as well as password fields, but including fields for
715 contact_email and contact_phone records.)
719 sub cgi_contact_fields {
722 my @contact_fields = qw(
723 classnum first last title comment emailaddress selfservice_access
726 push @contact_fields, 'phonetypenum'. $_->phonetypenum
727 foreach qsearch({table=>'phone_type', order_by=>'weight'});
741 L<FS::Record>, schema.html from the base documentation.