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+)$/ }
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);
281 # if new value is empty, delete old entry
282 if (!$self->get($pf)) {
283 if ($contact_phone) {
284 $error = $contact_phone->delete;
286 $dbh->rollback if $oldAutoCommit;
293 $contact_phone ||= new FS::contact_phone \%cp;
295 my %cpd = _parse_phonestring( $self->get($pf) );
296 $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
298 my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
300 $error = $contact_phone->$method;
302 $dbh->rollback if $oldAutoCommit;
307 if ( defined($self->hashref->{'emailaddress'}) ) {
309 #ineffecient but whatever, how many email addresses can there be?
311 foreach my $contact_email ( $self->contact_email ) {
312 my $error = $contact_email->delete;
314 $dbh->rollback if $oldAutoCommit;
319 foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
321 my $contact_email = new FS::contact_email {
322 'contactnum' => $self->contactnum,
323 'emailaddress' => $email,
325 $error = $contact_email->insert;
327 $dbh->rollback if $oldAutoCommit;
335 unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
336 #warn " queueing fuzzyfiles update\n"
338 $error = $self->queue_fuzzyfiles_update;
340 $dbh->rollback if $oldAutoCommit;
341 return "updating fuzzy search cache: $error";
345 if ( ( $old->selfservice_access eq '' && $self->selfservice_access
346 && ! $self->_password
351 my $error = $self->send_reset_email( queue=>1 );
353 $dbh->rollback if $oldAutoCommit;
358 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
364 =item _parse_phonestring PHONENUMBER_STRING
366 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
367 with keys 'countrycode', 'phonenum' and 'extension'
369 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
373 sub _parse_phonestring {
376 my($countrycode, $extension) = ('1', '');
379 if ( $value =~ s/^\s*\+\s*(\d+)// ) {
385 if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
389 ( 'countrycode' => $countrycode,
390 'phonenum' => $value,
391 'extension' => $extension,
395 =item queue_fuzzyfiles_update
397 Used by insert & replace to update the fuzzy search cache
401 use FS::cust_main::Search;
402 sub queue_fuzzyfiles_update {
405 local $SIG{HUP} = 'IGNORE';
406 local $SIG{INT} = 'IGNORE';
407 local $SIG{QUIT} = 'IGNORE';
408 local $SIG{TERM} = 'IGNORE';
409 local $SIG{TSTP} = 'IGNORE';
410 local $SIG{PIPE} = 'IGNORE';
412 my $oldAutoCommit = $FS::UID::AutoCommit;
413 local $FS::UID::AutoCommit = 0;
416 foreach my $field ( 'first', 'last' ) {
417 my $queue = new FS::queue {
418 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
420 my @args = "contact.$field", $self->get($field);
421 my $error = $queue->insert( @args );
423 $dbh->rollback if $oldAutoCommit;
424 return "queueing job (transaction rolled back): $error";
428 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
435 Checks all fields to make sure this is a valid contact. If there is
436 an error, returns the error, otherwise returns false. Called by the insert
444 if ( $self->selfservice_access eq 'R' ) {
445 $self->selfservice_access('Y');
450 $self->ut_numbern('contactnum')
451 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
452 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
453 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
454 || $self->ut_foreign_keyn('classnum', 'contact_class', 'classnum')
455 || $self->ut_namen('last')
456 || $self->ut_namen('first')
457 || $self->ut_textn('title')
458 || $self->ut_textn('comment')
459 || $self->ut_enum('selfservice_access', [ '', 'Y' ])
460 || $self->ut_textn('_password')
461 || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
462 || $self->ut_enum('disabled', [ '', 'Y' ])
464 return $error if $error;
466 return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
467 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
469 return "One of first name, last name, or title must have a value"
470 if ! grep $self->$_(), qw( first last title);
477 Returns a formatted string representing this contact, including name, title and
484 my $data = $self->first. ' '. $self->last;
485 $data .= ', '. $self->title
487 $data .= ' ('. $self->comment. ')'
494 Returns a formatted string representing this contact, with just the name.
500 $self->first . ' ' . $self->last;
503 =item contact_classname
505 Returns the name of this contact's class (see L<FS::contact_class>).
509 sub contact_classname {
511 my $contact_class = $self->contact_class or return '';
512 $contact_class->classname;
515 =item by_selfservice_email EMAILADDRESS
517 Alternate search constructor (class method). Given an email address,
518 returns the contact for that address, or the empty string if no contact
519 has that email address.
523 sub by_selfservice_email {
524 my($class, $email) = @_;
526 my $contact_email = qsearchs({
527 'table' => 'contact_email',
528 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
529 'hashref' => { 'emailaddress' => $email, },
530 'extra_sql' => " AND selfservice_access = 'Y' ".
531 " AND ( disabled IS NULL OR disabled = '' )",
534 $contact_email->contact;
538 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
539 # and should maybe be libraried in some way for other password needs
541 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
543 sub authenticate_password {
544 my($self, $check_password) = @_;
546 if ( $self->_password_encoding eq 'bcrypt' ) {
548 my( $cost, $salt, $hash ) = split(',', $self->_password);
550 my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
552 salt => de_base64($salt),
558 $hash eq $check_hash;
562 return 0 if $self->_password eq '';
564 $self->_password eq $check_password;
570 sub change_password {
571 my($self, $new_password) = @_;
573 $self->change_password_fields( $new_password );
579 sub change_password_fields {
580 my($self, $new_password) = @_;
582 $self->_password_encoding('bcrypt');
586 my $salt = pack( 'C*', map int(rand(256)), 1..16 );
588 my $hash = bcrypt_hash( { key_nul => 1,
596 join(',', $cost, en_base64($salt), en_base64($hash) )
601 # end of false laziness w/FS/FS/Auth/internal.pm
604 #false laziness w/ClientAPI/MyAccount/reset_passwd
605 use Digest::SHA qw(sha512_hex);
607 use FS::ClientAPI_SessionCache;
608 sub send_reset_email {
609 my( $self, %opt ) = @_;
611 my @contact_email = $self->contact_email or return '';
613 my $reset_session = {
614 'contactnum' => $self->contactnum,
615 'svcnum' => $opt{'svcnum'},
618 my $timeout = '24 hours'; #?
620 my $reset_session_id;
622 $reset_session_id = sha512_hex(time(). {}. rand(). $$)
623 } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
626 $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
630 my $conf = new FS::Conf;
632 my $cust_main = $self->cust_main
633 or die "no customer"; #reset a password for a prospect contact? someday
635 my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
636 #die "selfservice-password_reset_msgnum unset" unless $msgnum;
637 return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
638 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
640 'to' => join(',', map $_->emailaddress, @contact_email ),
641 'cust_main' => $cust_main,
643 'substitutions' => { 'session_id' => $reset_session_id }
646 if ( $opt{'queue'} ) { #or should queueing just be the default?
648 my $queue = new FS::queue {
649 'job' => 'FS::Misc::process_send_email',
650 'custnum' => $cust_main->custnum,
652 $queue->insert( $msg_template->prepare( %msg_template ) );
656 $msg_template->send( %msg_template );
662 use vars qw( $myaccount_cache );
663 sub myaccount_cache {
665 $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
666 'namespace' => 'FS::ClientAPI::MyAccount',
670 =item cgi_contact_fields
672 Returns a list reference containing the set of contact fields used in the web
673 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
674 and locationnum, as well as password fields, but including fields for
675 contact_email and contact_phone records.)
679 sub cgi_contact_fields {
682 my @contact_fields = qw(
683 classnum first last title comment emailaddress selfservice_access
686 push @contact_fields, 'phonetypenum'. $_->phonetypenum
687 foreach qsearch({table=>'phone_type', order_by=>'weight'});
701 L<FS::Record>, schema.html from the base documentation.