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 );
11 use FS::phone_type; #for cgi_contact_fields
17 FS::contact - Object methods for contact records
23 $record = new FS::contact \%hash;
24 $record = new FS::contact { 'column' => 'value' };
26 $error = $record->insert;
28 $error = $new_record->replace($old_record);
30 $error = $record->delete;
32 $error = $record->check;
36 An FS::contact object represents an specific contact person for a prospect or
37 customer. FS::contact inherits from FS::Record. The following fields are
74 =item selfservice_access
80 =item _password_encoding
97 Creates a new contact. To add the contact to the database, see L<"insert">.
99 Note that this stores the hash reference, not a distinct copy of the hash it
100 points to. You can ask the object for a copy with the I<hash> method.
104 sub table { 'contact'; }
108 Adds this record to the database. If there is an error, returns the error,
109 otherwise returns false.
116 local $SIG{INT} = 'IGNORE';
117 local $SIG{QUIT} = 'IGNORE';
118 local $SIG{TERM} = 'IGNORE';
119 local $SIG{TSTP} = 'IGNORE';
120 local $SIG{PIPE} = 'IGNORE';
122 my $oldAutoCommit = $FS::UID::AutoCommit;
123 local $FS::UID::AutoCommit = 0;
126 my $error = $self->SUPER::insert;
128 $dbh->rollback if $oldAutoCommit;
132 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
133 keys %{ $self->hashref } ) {
134 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
135 my $phonetypenum = $1;
137 my $contact_phone = new FS::contact_phone {
138 'contactnum' => $self->contactnum,
139 'phonetypenum' => $phonetypenum,
140 _parse_phonestring( $self->get($pf) ),
142 $error = $contact_phone->insert;
144 $dbh->rollback if $oldAutoCommit;
149 if ( $self->get('emailaddress') =~ /\S/ ) {
151 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
153 my $contact_email = new FS::contact_email {
154 'contactnum' => $self->contactnum,
155 'emailaddress' => $email,
157 $error = $contact_email->insert;
159 $dbh->rollback if $oldAutoCommit;
167 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
168 #warn " queueing fuzzyfiles update\n"
170 $error = $self->queue_fuzzyfiles_update;
172 $dbh->rollback if $oldAutoCommit;
173 return "updating fuzzy search cache: $error";
177 if ( $self->selfservice_access ) {
178 my $error = $self->send_reset_email( queue=>1 );
180 $dbh->rollback if $oldAutoCommit;
185 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
193 Delete this record from the database.
200 local $SIG{HUP} = 'IGNORE';
201 local $SIG{INT} = 'IGNORE';
202 local $SIG{QUIT} = 'IGNORE';
203 local $SIG{TERM} = 'IGNORE';
204 local $SIG{TSTP} = 'IGNORE';
205 local $SIG{PIPE} = 'IGNORE';
207 my $oldAutoCommit = $FS::UID::AutoCommit;
208 local $FS::UID::AutoCommit = 0;
211 foreach my $cust_pkg ( $self->cust_pkg ) {
212 $cust_pkg->contactnum('');
213 my $error = $cust_pkg->replace;
215 $dbh->rollback if $oldAutoCommit;
220 foreach my $object ( $self->contact_phone, $self->contact_email ) {
221 my $error = $object->delete;
223 $dbh->rollback if $oldAutoCommit;
228 my $error = $self->SUPER::delete;
230 $dbh->rollback if $oldAutoCommit;
234 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
239 =item replace OLD_RECORD
241 Replaces the OLD_RECORD with this one in the database. If there is an error,
242 returns the error, otherwise returns false.
249 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
251 : $self->replace_old;
253 $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
255 local $SIG{INT} = 'IGNORE';
256 local $SIG{QUIT} = 'IGNORE';
257 local $SIG{TERM} = 'IGNORE';
258 local $SIG{TSTP} = 'IGNORE';
259 local $SIG{PIPE} = 'IGNORE';
261 my $oldAutoCommit = $FS::UID::AutoCommit;
262 local $FS::UID::AutoCommit = 0;
265 my $error = $self->SUPER::replace($old);
267 $dbh->rollback if $oldAutoCommit;
271 foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) }
272 keys %{ $self->hashref } ) {
273 $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
274 my $phonetypenum = $1;
276 my %cp = ( 'contactnum' => $self->contactnum,
277 'phonetypenum' => $phonetypenum,
279 my $contact_phone = qsearchs('contact_phone', \%cp)
280 || new FS::contact_phone \%cp;
282 my %cpd = _parse_phonestring( $self->get($pf) );
283 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
285 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
287 $error = $contact_phone->$method;
289 $dbh->rollback if $oldAutoCommit;
294 if ( defined($self->hashref->{'emailaddress'}) ) {
296 #ineffecient but whatever, how many email addresses can there be?
298 foreach my $contact_email ( $self->contact_email ) {
299 my $error = $contact_email->delete;
301 $dbh->rollback if $oldAutoCommit;
306 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
308 my $contact_email = new FS::contact_email {
309 'contactnum' => $self->contactnum,
310 'emailaddress' => $email,
312 $error = $contact_email->insert;
314 $dbh->rollback if $oldAutoCommit;
322 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
323 #warn " queueing fuzzyfiles update\n"
325 $error = $self->queue_fuzzyfiles_update;
327 $dbh->rollback if $oldAutoCommit;
328 return "updating fuzzy search cache: $error";
332 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
333 && ! $self->_password
338 my $error = $self->send_reset_email( queue=>1 );
340 $dbh->rollback if $oldAutoCommit;
345 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
351 =item _parse_phonestring PHONENUMBER_STRING
353 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
354 with keys 'countrycode', 'phonenum' and 'extension'
356 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
360 sub _parse_phonestring {
363 my($countrycode, $extension) = ('1', '');
366 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
372 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
376 ( 'countrycode' => $countrycode,
377 'phonenum' => $value,
378 'extension' => $extension,
382 =item queue_fuzzyfiles_update
384 Used by insert & replace to update the fuzzy search cache
388 use FS::cust_main::Search;
389 sub queue_fuzzyfiles_update {
392 local $SIG{HUP} = 'IGNORE';
393 local $SIG{INT} = 'IGNORE';
394 local $SIG{QUIT} = 'IGNORE';
395 local $SIG{TERM} = 'IGNORE';
396 local $SIG{TSTP} = 'IGNORE';
397 local $SIG{PIPE} = 'IGNORE';
399 my $oldAutoCommit = $FS::UID::AutoCommit;
400 local $FS::UID::AutoCommit = 0;
403 foreach my $field ( 'first', 'last' ) {
404 my $queue = new FS::queue {
405 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
407 my @args = "contact.$field", $self->get($field);
408 my $error = $queue->insert( @args );
410 $dbh->rollback if $oldAutoCommit;
411 return "queueing job (transaction rolled back): $error";
415 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
422 Checks all fields to make sure this is a valid contact. If there is
423 an error, returns the error, otherwise returns false. Called by the insert
431 if ( $self->selfservice_access eq 'R' ) {
432 $self->selfservice_access('Y');
437 $self->ut_numbern('contactnum')
438 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
439 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
440 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
441 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
442 || $self->ut_namen('last')
443 || $self->ut_namen('first')
444 || $self->ut_textn('title')
445 || $self->ut_textn('comment')
446 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
447 || $self->ut_textn('_password')
448 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
449 || $self->ut_enum('disabled', [ '', 'Y' ])
451 return $error if $error;
453 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
454 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
456 return "One of first name, last name, or title must have a value"
457 if ! grep $self->$_(), qw( first last title);
464 Returns a formatted string representing this contact, including name, title and
471 my $data = $self->first. ' '. $self->last;
472 $data .= ', '. $self->title
474 $data .= ' ('. $self->comment. ')'
481 Returns a formatted string representing this contact, with just the name.
487 $self->first . ' ' . $self->last;
490 =item contact_classname
492 Returns the name of this contact's class (see L<FS::contact_class>).
496 sub contact_classname {
498 my $contact_class = $self->contact_class or return '';
499 $contact_class->classname;
502 =item by_selfservice_email EMAILADDRESS
504 Alternate search constructor (class method). Given an email address,
505 returns the contact for that address, or the empty string if no contact
506 has that email address.
510 sub by_selfservice_email {
511 my($class, $email) = @_;
513 my $contact_email = qsearchs({
514 'table' => 'contact_email',
515 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
516 'hashref' => { 'emailaddress' => $email, },
517 'extra_sql' => " AND selfservice_access = 'Y' ".
518 " AND ( disabled IS NULL OR disabled = '' )",
521 $contact_email->contact;
525 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
526 # and should maybe be libraried in some way for other password needs
528 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
530 sub authenticate_password {
531 my($self, $check_password) = @_;
533 if ( $self->_password_encoding eq 'bcrypt' ) {
535 my( $cost, $salt, $hash ) = split(',', $self->_password);
537 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
539 salt => de_base64($salt),
545 $hash eq $check_hash;
549 return 0 if $self->_password eq '';
551 $self->_password eq $check_password;
557 sub change_password {
558 my($self, $new_password) = @_;
560 $self->change_password_fields( $new_password );
566 sub change_password_fields {
567 my($self, $new_password) = @_;
569 $self->_password_encoding('bcrypt');
573 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
575 my $hash = bcrypt_hash( { key_nul => 1,
583 join(',', $cost, en_base64($salt), en_base64($hash) )
588 # end of false laziness w/FS/FS/Auth/internal.pm
591 #false laziness w/ClientAPI/MyAccount/reset_passwd
592 use Digest::SHA qw(sha512_hex);
594 use FS::ClientAPI_SessionCache;
595 sub send_reset_email {
596 my( $self, %opt ) = @_;
598 my @contact_email = $self->contact_email or return '';
600 my $reset_session = {
601 'contactnum' => $self->contactnum,
602 'svcnum' => $opt{'svcnum'},
605 my $timeout = '24 hours'; #?
607 my $reset_session_id;
609 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
610 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
613 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
617 my $conf = new FS::Conf;
619 my $cust_main = $self->cust_main
620 or die "no customer"; #reset a password for a prospect contact? someday
622 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
623 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
624 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
625 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
627 'to' => join(',', map $_->emailaddress, @contact_email ),
628 'cust_main' => $cust_main,
630 'substitutions' => { 'session_id' => $reset_session_id }
633 if ( $opt{'queue'} ) { #or should queueing just be the default?
635 my $queue = new FS::queue {
636 'job' => 'FS::Misc::process_send_email',
637 'custnum' => $cust_main->custnum,
639 $queue->insert( $msg_template->prepare( %msg_template ) );
643 $msg_template->send( %msg_template );
649 use vars qw( $myaccount_cache );
650 sub myaccount_cache {
652 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
653 'namespace' => 'FS::ClientAPI::MyAccount',
657 =item cgi_contact_fields
659 Returns a list reference containing the set of contact fields used in the web
660 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
661 and locationnum, as well as password fields, but including fields for
662 contact_email and contact_phone records.)
666 sub cgi_contact_fields {
669 my @contact_fields = qw(
670 classnum first last title comment emailaddress selfservice_access
673 push @contact_fields, 'phonetypenum'. $_->phonetypenum
674 foreach qsearch({table=>'phone_type', order_by=>'weight'});
688 L<FS::Record>, schema.html from the base documentation.