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+)$/ && $self->get($_) }
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)
285 || new FS::contact_phone \%cp;
287 my %cpd = _parse_phonestring( $self->get($pf) );
288 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
290 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
292 $error = $contact_phone->$method;
294 $dbh->rollback if $oldAutoCommit;
299 if ( defined($self->hashref->{'emailaddress'}) ) {
301 #ineffecient but whatever, how many email addresses can there be?
303 foreach my $contact_email ( $self->contact_email ) {
304 my $error = $contact_email->delete;
306 $dbh->rollback if $oldAutoCommit;
311 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
313 my $contact_email = new FS::contact_email {
314 'contactnum' => $self->contactnum,
315 'emailaddress' => $email,
317 $error = $contact_email->insert;
319 $dbh->rollback if $oldAutoCommit;
327 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
328 #warn " queueing fuzzyfiles update\n"
330 $error = $self->queue_fuzzyfiles_update;
332 $dbh->rollback if $oldAutoCommit;
333 return "updating fuzzy search cache: $error";
337 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
338 && ! $self->_password
343 my $error = $self->send_reset_email( queue=>1 );
345 $dbh->rollback if $oldAutoCommit;
350 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
356 =item _parse_phonestring PHONENUMBER_STRING
358 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
359 with keys 'countrycode', 'phonenum' and 'extension'
361 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
365 sub _parse_phonestring {
368 my($countrycode, $extension) = ('1', '');
371 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
377 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
381 ( 'countrycode' => $countrycode,
382 'phonenum' => $value,
383 'extension' => $extension,
387 =item queue_fuzzyfiles_update
389 Used by insert & replace to update the fuzzy search cache
393 use FS::cust_main::Search;
394 sub queue_fuzzyfiles_update {
397 local $SIG{HUP} = 'IGNORE';
398 local $SIG{INT} = 'IGNORE';
399 local $SIG{QUIT} = 'IGNORE';
400 local $SIG{TERM} = 'IGNORE';
401 local $SIG{TSTP} = 'IGNORE';
402 local $SIG{PIPE} = 'IGNORE';
404 my $oldAutoCommit = $FS::UID::AutoCommit;
405 local $FS::UID::AutoCommit = 0;
408 foreach my $field ( 'first', 'last' ) {
409 my $queue = new FS::queue {
410 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
412 my @args = "contact.$field", $self->get($field);
413 my $error = $queue->insert( @args );
415 $dbh->rollback if $oldAutoCommit;
416 return "queueing job (transaction rolled back): $error";
420 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
427 Checks all fields to make sure this is a valid contact. If there is
428 an error, returns the error, otherwise returns false. Called by the insert
436 if ( $self->selfservice_access eq 'R' ) {
437 $self->selfservice_access('Y');
442 $self->ut_numbern('contactnum')
443 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
444 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
445 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
446 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
447 || $self->ut_namen('last')
448 || $self->ut_namen('first')
449 || $self->ut_textn('title')
450 || $self->ut_textn('comment')
451 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
452 || $self->ut_textn('_password')
453 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
454 || $self->ut_enum('disabled', [ '', 'Y' ])
456 return $error if $error;
458 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
459 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
461 return "One of first name, last name, or title must have a value"
462 if ! grep $self->$_(), qw( first last title);
469 Returns a formatted string representing this contact, including name, title and
476 my $data = $self->first. ' '. $self->last;
477 $data .= ', '. $self->title
479 $data .= ' ('. $self->comment. ')'
486 return '' unless $self->locationnum;
487 qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
492 return '' unless $self->classnum;
493 qsearchs('contact_class', { 'classnum' => $self->classnum } );
498 Returns a formatted string representing this contact, with just the name.
504 $self->first . ' ' . $self->last;
507 =item contact_classname
509 Returns the name of this contact's class (see L<FS::contact_class>).
513 sub contact_classname {
515 my $contact_class = $self->contact_class or return '';
516 $contact_class->classname;
521 qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
526 qsearch('contact_email', { 'contactnum' => $self->contactnum } );
531 qsearchs('cust_main', { 'custnum' => $self->custnum } );
536 qsearch('cust_pkg', { 'contactnum' => $self->contactnum } );
539 =item by_selfservice_email EMAILADDRESS
541 Alternate search constructor (class method). Given an email address,
542 returns the contact for that address, or the empty string if no contact
543 has that email address.
547 sub by_selfservice_email {
548 my($class, $email) = @_;
550 my $contact_email = qsearchs({
551 'table' => 'contact_email',
552 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
553 'hashref' => { 'emailaddress' => $email, },
554 'extra_sql' => " AND selfservice_access = 'Y' ".
555 " AND ( disabled IS NULL OR disabled = '' )",
558 $contact_email->contact;
562 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
563 # and should maybe be libraried in some way for other password needs
565 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
567 sub authenticate_password {
568 my($self, $check_password) = @_;
570 if ( $self->_password_encoding eq 'bcrypt' ) {
572 my( $cost, $salt, $hash ) = split(',', $self->_password);
574 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
576 salt => de_base64($salt),
582 $hash eq $check_hash;
586 return 0 if $self->_password eq '';
588 $self->_password eq $check_password;
594 sub change_password {
595 my($self, $new_password) = @_;
597 $self->change_password_fields( $new_password );
603 sub change_password_fields {
604 my($self, $new_password) = @_;
606 $self->_password_encoding('bcrypt');
610 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
612 my $hash = bcrypt_hash( { key_nul => 1,
620 join(',', $cost, en_base64($salt), en_base64($hash) )
625 # end of false laziness w/FS/FS/Auth/internal.pm
628 #false laziness w/ClientAPI/MyAccount/reset_passwd
629 use Digest::SHA qw(sha512_hex);
631 use FS::ClientAPI_SessionCache;
632 sub send_reset_email {
633 my( $self, %opt ) = @_;
635 my @contact_email = $self->contact_email or return '';
637 my $reset_session = {
638 'contactnum' => $self->contactnum,
639 'svcnum' => $opt{'svcnum'},
642 my $timeout = '24 hours'; #?
644 my $reset_session_id;
646 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
647 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
650 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
654 my $conf = new FS::Conf;
656 my $cust_main = $self->cust_main
657 or die "no customer"; #reset a password for a prospect contact? someday
659 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
660 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
661 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
662 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
664 'to' => join(',', map $_->emailaddress, @contact_email ),
665 'cust_main' => $cust_main,
667 'substitutions' => { 'session_id' => $reset_session_id }
670 if ( $opt{'queue'} ) { #or should queueing just be the default?
672 my $queue = new FS::queue {
673 'job' => 'FS::Misc::process_send_email',
674 'custnum' => $cust_main->custnum,
676 $queue->insert( $msg_template->prepare( %msg_template ) );
680 $msg_template->send( %msg_template );
686 use vars qw( $myaccount_cache );
687 sub myaccount_cache {
689 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
690 'namespace' => 'FS::ClientAPI::MyAccount',
694 =item cgi_contact_fields
696 Returns a list reference containing the set of contact fields used in the web
697 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
698 and locationnum, as well as password fields, but including fields for
699 contact_email and contact_phone records.)
703 sub cgi_contact_fields {
706 my @contact_fields = qw(
707 classnum first last title comment emailaddress selfservice_access
710 push @contact_fields, 'phonetypenum'. $_->phonetypenum
711 foreach qsearch({table=>'phone_type', order_by=>'weight'});
725 L<FS::Record>, schema.html from the base documentation.