RT# 82132 - updated selfservice login to use config username-uppercase
[freeside.git] / FS / FS / contact.pm
1 package FS::contact;
2 use base qw( FS::Password_Mixin
3              FS::Record );
4
5 use strict;
6 use vars qw( $skip_fuzzyfiles );
7 use Scalar::Util qw( blessed );
8 use FS::Record qw( qsearch qsearchs dbh );
9 use FS::prospect_main;
10 use FS::cust_main;
11 use FS::contact_class;
12 use FS::cust_location;
13 use FS::contact_phone;
14 use FS::contact_email;
15 use FS::contact::Import;
16 use FS::queue;
17 use FS::cust_pkg;
18 use FS::phone_type; #for cgi_contact_fields
19
20 $skip_fuzzyfiles = 0;
21
22 =head1 NAME
23
24 FS::contact - Object methods for contact records
25
26 =head1 SYNOPSIS
27
28   use FS::contact;
29
30   $record = new FS::contact \%hash;
31   $record = new FS::contact { 'column' => 'value' };
32
33   $error = $record->insert;
34
35   $error = $new_record->replace($old_record);
36
37   $error = $record->delete;
38
39   $error = $record->check;
40
41 =head1 DESCRIPTION
42
43 An FS::contact object represents an specific contact person for a prospect or
44 customer.  FS::contact inherits from FS::Record.  The following fields are
45 currently supported:
46
47 =over 4
48
49 =item contactnum
50
51 primary key
52
53 =item prospectnum
54
55 prospectnum
56
57 =item custnum
58
59 custnum
60
61 =item locationnum
62
63 locationnum
64
65 =item last
66
67 last
68
69 =item first
70
71 first
72
73 =item title
74
75 title
76
77 =item comment
78
79 comment
80
81 =item selfservice_access
82
83 empty or Y
84
85 =item _password
86
87 =item _password_encoding
88
89 empty or bcrypt
90
91 =item disabled
92
93 disabled
94
95
96 =back
97
98 =head1 METHODS
99
100 =over 4
101
102 =item new HASHREF
103
104 Creates a new contact.  To add the contact to the database, see L<"insert">.
105
106 Note that this stores the hash reference, not a distinct copy of the hash it
107 points to.  You can ask the object for a copy with the I<hash> method.
108
109 =cut
110
111 sub table { 'contact'; }
112
113 =item insert
114
115 Adds this record to the database.  If there is an error, returns the error,
116 otherwise returns false.
117
118 =cut
119
120 sub insert {
121   my $self = shift;
122
123   local $SIG{INT} = 'IGNORE';
124   local $SIG{QUIT} = 'IGNORE';
125   local $SIG{TERM} = 'IGNORE';
126   local $SIG{TSTP} = 'IGNORE';
127   local $SIG{PIPE} = 'IGNORE';
128
129   my $oldAutoCommit = $FS::UID::AutoCommit;
130   local $FS::UID::AutoCommit = 0;
131   my $dbh = dbh;
132
133   my $error = $self->SUPER::insert;
134
135   if ( $error ) {
136     $dbh->rollback if $oldAutoCommit;
137     return $error;
138   }
139
140   foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
141                         keys %{ $self->hashref } ) {
142     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
143     my $phonetypenum = $1;
144
145     my $contact_phone = new FS::contact_phone {
146       'contactnum' => $self->contactnum,
147       'phonetypenum' => $phonetypenum,
148       _parse_phonestring( $self->get($pf) ),
149     };
150     $error = $contact_phone->insert;
151     if ( $error ) {
152       $dbh->rollback if $oldAutoCommit;
153       return $error;
154     }
155   }
156
157   if ( $self->get('emailaddress') =~ /\S/ ) {
158
159     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
160  
161       my $contact_email = new FS::contact_email {
162         'contactnum'   => $self->contactnum,
163         'emailaddress' => $email,
164       };
165       $error = $contact_email->insert;
166       if ( $error ) {
167         $dbh->rollback if $oldAutoCommit;
168         return $error;
169       }
170
171     }
172
173   }
174
175   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
176     #warn "  queueing fuzzyfiles update\n"
177     #  if $DEBUG > 1;
178     $error = $self->queue_fuzzyfiles_update;
179     if ( $error ) {
180       $dbh->rollback if $oldAutoCommit;
181       return "updating fuzzy search cache: $error";
182     }
183   }
184
185   if ( $self->selfservice_access && ! length($self->_password) ) {
186     my $error = $self->send_reset_email( queue=>1 );
187     if ( $error ) {
188       $dbh->rollback if $oldAutoCommit;
189       return $error;
190     }
191   }
192
193   if ( $self->get('password') ) {
194     my $error = $self->is_password_allowed($self->get('password'))
195           ||  $self->change_password($self->get('password'));
196     if ( $error ) {
197       $dbh->rollback if $oldAutoCommit;
198       return $error;
199     }
200   }
201
202   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
203
204   '';
205
206 }
207
208 =item delete
209
210 Delete this record from the database.
211
212 =cut
213
214 sub delete {
215   my $self = shift;
216
217   local $SIG{HUP} = 'IGNORE';
218   local $SIG{INT} = 'IGNORE';
219   local $SIG{QUIT} = 'IGNORE';
220   local $SIG{TERM} = 'IGNORE';
221   local $SIG{TSTP} = 'IGNORE';
222   local $SIG{PIPE} = 'IGNORE';
223
224   my $oldAutoCommit = $FS::UID::AutoCommit;
225   local $FS::UID::AutoCommit = 0;
226   my $dbh = dbh;
227
228   foreach my $cust_pkg ( $self->cust_pkg ) {
229     $cust_pkg->contactnum('');
230     my $error = $cust_pkg->replace;
231     if ( $error ) {
232       $dbh->rollback if $oldAutoCommit;
233       return $error;
234     }
235   }
236
237   foreach my $object ( $self->contact_phone, $self->contact_email ) {
238     my $error = $object->delete;
239     if ( $error ) {
240       $dbh->rollback if $oldAutoCommit;
241       return $error;
242     }
243   }
244
245   my $error = $self->delete_password_history
246            || $self->SUPER::delete;
247   if ( $error ) {
248     $dbh->rollback if $oldAutoCommit;
249     return $error;
250   }
251
252   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
253   '';
254
255 }
256
257 =item replace OLD_RECORD
258
259 Replaces the OLD_RECORD with this one in the database.  If there is an error,
260 returns the error, otherwise returns false.
261
262 =cut
263
264 sub replace {
265   my $self = shift;
266
267   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
268               ? shift
269               : $self->replace_old;
270
271   $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
272
273   local $SIG{INT} = 'IGNORE';
274   local $SIG{QUIT} = 'IGNORE';
275   local $SIG{TERM} = 'IGNORE';
276   local $SIG{TSTP} = 'IGNORE';
277   local $SIG{PIPE} = 'IGNORE';
278
279   my $oldAutoCommit = $FS::UID::AutoCommit;
280   local $FS::UID::AutoCommit = 0;
281   my $dbh = dbh;
282
283   my $error = $self->SUPER::replace($old);
284   if ( $old->_password ne $self->_password ) {
285     $error ||= $self->insert_password_history;
286   }
287   if ( $error ) {
288     $dbh->rollback if $oldAutoCommit;
289     return $error;
290   }
291
292   foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
293                         keys %{ $self->hashref } ) {
294     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
295     my $phonetypenum = $1;
296
297     my %cp = ( 'contactnum'   => $self->contactnum,
298                'phonetypenum' => $phonetypenum,
299              );
300     my $contact_phone = qsearchs('contact_phone', \%cp);
301
302     my $pv = $self->get($pf);
303         $pv =~ s/\s//g;
304
305     #if new value is empty, delete old entry
306     if (!$pv) {
307       if ($contact_phone) {
308         $error = $contact_phone->delete;
309         if ( $error ) {
310           $dbh->rollback if $oldAutoCommit;
311           return $error;
312         }
313       }
314       next;
315     }
316
317     $contact_phone ||= new FS::contact_phone \%cp;
318
319     my %cpd = _parse_phonestring( $pv );
320     $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
321
322     my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
323
324     $error = $contact_phone->$method;
325     if ( $error ) {
326       $dbh->rollback if $oldAutoCommit;
327       return $error;
328     }
329   }
330
331   if ( defined($self->hashref->{'emailaddress'}) ) {
332
333     #ineffecient but whatever, how many email addresses can there be?
334
335     foreach my $contact_email ( $self->contact_email ) {
336       my $error = $contact_email->delete;
337       if ( $error ) {
338         $dbh->rollback if $oldAutoCommit;
339         return $error;
340       }
341     }
342
343     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
344  
345       my $contact_email = new FS::contact_email {
346         'contactnum'   => $self->contactnum,
347         'emailaddress' => $email,
348       };
349       $error = $contact_email->insert;
350       if ( $error ) {
351         $dbh->rollback if $oldAutoCommit;
352         return $error;
353       }
354
355     }
356
357   }
358
359   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
360     #warn "  queueing fuzzyfiles update\n"
361     #  if $DEBUG > 1;
362     $error = $self->queue_fuzzyfiles_update;
363     if ( $error ) {
364       $dbh->rollback if $oldAutoCommit;
365       return "updating fuzzy search cache: $error";
366     }
367   }
368
369   if (    ( $old->selfservice_access eq '' && $self->selfservice_access
370               && ! $self->_password
371           )
372        || $self->_resend()
373      )
374   {
375     my $error = $self->send_reset_email( queue=>1 );
376     if ( $error ) {
377       $dbh->rollback if $oldAutoCommit;
378       return $error;
379     }
380   }
381
382   if ( $self->get('password') ) {
383     my $error = $self->is_password_allowed($self->get('password'))
384           ||  $self->change_password($self->get('password'));
385     if ( $error ) {
386       $dbh->rollback if $oldAutoCommit;
387       return $error;
388     }
389   }
390
391   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
392
393   '';
394
395 }
396
397 =item _parse_phonestring PHONENUMBER_STRING
398
399 Subroutine, takes a string and returns a list (suitable for assigning to a hash)
400 with keys 'countrycode', 'phonenum' and 'extension'
401
402 (Should probably be moved to contact_phone.pm, hence the initial underscore.)
403
404 =cut
405
406 sub _parse_phonestring {
407   my $value = shift;
408
409   my($countrycode, $extension) = ('1', '');
410
411   #countrycode
412   if ( $value =~ s/^\s*\+\s*(\d+)// ) {
413     $countrycode = $1;
414   } else {
415     $value =~ s/^\s*1//;
416   }
417   #extension
418   if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
419      $extension = $2;
420   }
421
422   ( 'countrycode' => $countrycode,
423     'phonenum'    => $value,
424     'extension'   => $extension,
425   );
426 }
427
428 =item queue_fuzzyfiles_update
429
430 Used by insert & replace to update the fuzzy search cache
431
432 =cut
433
434 use FS::cust_main::Search;
435 sub queue_fuzzyfiles_update {
436   my $self = shift;
437
438   local $SIG{HUP} = 'IGNORE';
439   local $SIG{INT} = 'IGNORE';
440   local $SIG{QUIT} = 'IGNORE';
441   local $SIG{TERM} = 'IGNORE';
442   local $SIG{TSTP} = 'IGNORE';
443   local $SIG{PIPE} = 'IGNORE';
444
445   my $oldAutoCommit = $FS::UID::AutoCommit;
446   local $FS::UID::AutoCommit = 0;
447   my $dbh = dbh;
448
449   foreach my $field ( 'first', 'last' ) {
450     my $queue = new FS::queue { 
451       'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
452     };
453     my @args = "contact.$field", $self->get($field);
454     my $error = $queue->insert( @args );
455     if ( $error ) {
456       $dbh->rollback if $oldAutoCommit;
457       return "queueing job (transaction rolled back): $error";
458     }
459   }
460
461   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
462   '';
463
464 }
465
466 =item check
467
468 Checks all fields to make sure this is a valid contact.  If there is
469 an error, returns the error, otherwise returns false.  Called by the insert
470 and replace methods.
471
472 =cut
473
474 sub check {
475   my $self = shift;
476
477   if ( $self->selfservice_access eq 'R' || $self->selfservice_access eq 'E' || $self->selfservice_access eq 'P' ) {
478     $self->selfservice_access('Y');
479     $self->_resend('Y');
480   }
481
482   my $error = 
483     $self->ut_numbern('contactnum')
484     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
485     || $self->ut_foreign_keyn('custnum',     'cust_main',     'custnum')
486     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
487     || $self->ut_foreign_keyn('classnum',    'contact_class', 'classnum')
488     || $self->ut_namen('last')
489     || $self->ut_namen('first')
490     || $self->ut_textn('title')
491     || $self->ut_textn('comment')
492     || $self->ut_enum('selfservice_access', [ '', 'Y' ])
493     || $self->ut_textn('_password')
494     || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
495     || $self->ut_enum('disabled', [ '', 'Y' ])
496   ;
497   return $error if $error;
498
499   return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
500   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
501
502   return "One of first name, last name, or title must have a value"
503     if ! grep $self->$_(), qw( first last title);
504
505   $self->SUPER::check;
506 }
507
508 =item line
509
510 Returns a formatted string representing this contact, including name, title and
511 comment.
512
513 =cut
514
515 sub line {
516   my $self = shift;
517   my $data = $self->first. ' '. $self->last;
518   $data .= ', '. $self->title
519     if $self->title;
520   $data .= ' ('. $self->comment. ')'
521     if $self->comment;
522   $data;
523 }
524
525 sub cust_location {
526   my $self = shift;
527   return '' unless $self->locationnum;
528   qsearchs('cust_location', { 'locationnum' => $self->locationnum } );
529 }
530
531 sub contact_class {
532   my $self = shift;
533   return '' unless $self->classnum;
534   qsearchs('contact_class', { 'classnum' => $self->classnum } );
535 }
536
537 =item firstlast
538
539 Returns a formatted string representing this contact, with just the name.
540
541 =cut
542
543 sub firstlast {
544   my $self = shift;
545   $self->first . ' ' . $self->last;
546 }
547
548 =item contact_classname
549
550 Returns the name of this contact's class (see L<FS::contact_class>).
551
552 =cut
553
554 sub contact_classname {
555   my $self = shift;
556   my $contact_class = $self->contact_class or return '';
557   $contact_class->classname;
558 }
559
560 sub contact_phone {
561   my $self = shift;
562   qsearch('contact_phone', { 'contactnum' => $self->contactnum } );
563 }
564
565 sub contact_email {
566   my $self = shift;
567   qsearch('contact_email', { 'contactnum' => $self->contactnum } );
568 }
569
570 sub cust_main {
571   my $self = shift;
572   qsearchs('cust_main', { 'custnum' => $self->custnum  } );
573 }
574
575 sub cust_pkg {
576   my $self = shift;
577   qsearch('cust_pkg', { 'contactnum' => $self->contactnum  } );
578 }
579
580 =item by_selfservice_email EMAILADDRESS
581
582 Alternate search constructor (class method).  Given an email address,
583 returns the contact for that address, or the empty string if no contact
584 has that email address.
585
586 =cut
587
588 sub by_selfservice_email {
589   my($class, $email, $case_insensitive) = @_;
590
591   my $email_search = "emailaddress = '".$email."'";
592   $email_search = "LOWER(emailaddress) = LOWER('".$email."')" if $case_insensitive;
593
594   my $contact_email = qsearchs({
595     'table'     => 'contact_email',
596     'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
597     'extra_sql' => " WHERE $email_search".
598                    " AND selfservice_access = 'Y' ".
599                    " AND ( disabled IS NULL OR disabled = '' )",
600   }) or return '';
601
602   $contact_email->contact;
603
604 }
605
606 =item by_selfservice_email_custnum EMAILADDRESS, CUSTNUM
607
608 Alternate search constructor (class method).  Given an email address and custnum, returns
609 the contact for that address and custnum. If that contact doesn't have selfservice access,
610 or there isn't one, returns the empty string.
611
612 =cut
613
614 sub by_selfservice_email_custnum {
615   my($class, $email, $custnum) = @_;
616
617   my $contact_email = qsearchs({
618     'table'     => 'contact_email',
619     'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
620     'hashref'   => { 'emailaddress' => $email, },
621     'extra_sql' => " AND selfservice_access = 'Y' ".
622                    " AND ( disabled IS NULL OR disabled = '' )",
623   }) or return '';
624
625   $contact_email->contact;
626
627 }
628
629 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
630 # and should maybe be libraried in some way for other password needs
631
632 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
633
634 sub authenticate_password {
635   my($self, $check_password) = @_;
636
637   if ( $self->_password_encoding eq 'bcrypt' ) {
638
639     my( $cost, $salt, $hash ) = split(',', $self->_password);
640
641     my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
642                                                cost    => $cost,
643                                                salt    => de_base64($salt),
644                                              },
645                                              $check_password
646                                            )
647                               );
648
649     $hash eq $check_hash;
650
651   } else {
652
653     return 0 if $self->_password eq '';
654
655     $self->_password eq $check_password;
656
657   }
658
659 }
660
661 =item change_password NEW_PASSWORD
662
663 Changes the contact's selfservice access password to NEW_PASSWORD. This does
664 not check password policy rules (see C<is_password_allowed>) and will return
665 an error only if editing the record fails for some reason.
666
667 If NEW_PASSWORD is the same as the existing password, this does nothing.
668
669 =cut
670
671 sub change_password {
672   my($self, $new_password) = @_;
673
674   # do nothing if the password is unchanged
675   return if $self->authenticate_password($new_password);
676
677   $self->change_password_fields( $new_password );
678
679   $self->replace;
680
681 }
682
683 sub change_password_fields {
684   my($self, $new_password) = @_;
685
686   $self->_password_encoding('bcrypt');
687
688   my $cost = 8;
689
690   my $salt = pack( 'C*', map int(rand(256)), 1..16 );
691
692   my $hash = bcrypt_hash( { key_nul => 1,
693                             cost    => $cost,
694                             salt    => $salt,
695                           },
696                           $new_password,
697                         );
698
699   $self->_password(
700     join(',', $cost, en_base64($salt), en_base64($hash) )
701   );
702
703 }
704
705 # end of false laziness w/FS/FS/Auth/internal.pm
706
707
708 #false laziness w/ClientAPI/MyAccount/reset_passwd
709 use Digest::SHA qw(sha512_hex);
710 use FS::Conf;
711 use FS::ClientAPI_SessionCache;
712 sub send_reset_email {
713   my( $self, %opt ) = @_;
714
715   my @contact_email = $self->contact_email or return '';
716
717   my $reset_session = {
718     'contactnum' => $self->contactnum,
719     'svcnum'     => $opt{'svcnum'},
720   };
721
722   
723   my $conf = new FS::Conf;
724   my $timeout =
725     ($conf->config('selfservice-password_reset_hours') || 24 ). ' hours';
726
727   my $reset_session_id;
728   do {
729     $reset_session_id = sha512_hex(time(). {}. rand(). $$)
730   } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
731     #just in case
732
733   $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
734
735   #email it
736
737   my $cust_main = $self->cust_main
738     or die "no customer"; #reset a password for a prospect contact?  someday
739
740   my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
741   #die "selfservice-password_reset_msgnum unset" unless $msgnum;
742   return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
743   my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
744   my %msg_template = (
745     'to'            => join(',', map $_->emailaddress, @contact_email ),
746     'cust_main'     => $cust_main,
747     'object'        => $self,
748     'substitutions' => { 'session_id' => $reset_session_id }
749   );
750
751   if ( $opt{'queue'} ) { #or should queueing just be the default?
752
753     my $queue = new FS::queue {
754       'job'     => 'FS::Misc::process_send_email',
755       'custnum' => $cust_main->custnum,
756     };
757     $queue->insert( $msg_template->prepare( %msg_template ) );
758
759   } else {
760
761     $msg_template->send( %msg_template );
762
763   }
764
765 }
766
767 use vars qw( $myaccount_cache );
768 sub myaccount_cache {
769   #my $class = shift;
770   $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
771                          'namespace' => 'FS::ClientAPI::MyAccount',
772                        } );
773 }
774
775 =item cgi_contact_fields
776
777 Returns a list reference containing the set of contact fields used in the web
778 interface for one-line editing (i.e. excluding contactnum, prospectnum, custnum
779 and locationnum, as well as password fields, but including fields for
780 contact_email and contact_phone records.)
781
782 =cut
783
784 sub cgi_contact_fields {
785   #my $class = shift;
786
787   my @contact_fields = qw(
788     classnum first last title comment emailaddress selfservice_access
789     invoice_dest password
790   );
791
792   push @contact_fields, 'phonetypenum'. $_->phonetypenum
793     foreach qsearch({table=>'phone_type', order_by=>'weight'});
794
795   \@contact_fields;
796
797 }
798
799 use FS::phone_type;
800
801 =back
802
803 =head1 BUGS
804
805 =head1 SEE ALSO
806
807 L<FS::Record>, schema.html from the base documentation.
808
809 =cut
810
811 1;
812