default to a session cookie instead of setting an explicit timeout, weird timezone...
[freeside.git] / FS / FS / contact.pm
index 589fc7c..26f39ed 100644 (file)
@@ -1,13 +1,16 @@
 package FS::contact;
-use base qw( FS::Record );
+use base qw( FS::Password_Mixin
+             FS::Record );
 
 use strict;
 use vars qw( $skip_fuzzyfiles );
 use Carp;
 use Scalar::Util qw( blessed );
 use FS::Record qw( qsearch qsearchs dbh );
+use FS::Cursor;
 use FS::contact_phone;
 use FS::contact_email;
+use FS::contact::Import;
 use FS::queue;
 use FS::phone_type; #for cgi_contact_fields
 use FS::cust_contact;
@@ -88,7 +91,6 @@ empty or bcrypt
 
 disabled
 
-
 =back
 
 =head1 METHODS
@@ -111,6 +113,26 @@ sub table { 'contact'; }
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
+If the object has an C<emailaddress> field, L<FS::contact_email> records
+will be created for each (comma-separated) email address in that field. If
+any of these coincide with an existing email address, this contact will be
+merged with the contact with that address.
+
+Then, if the object has any fields named C<phonetypenumN> an
+L<FS::contact_phone> record will be created for each of them. Those fields
+should contain phone numbers of the appropriate types (where N is the key of
+an L<FS::phone_type> record identifying the type of number: daytime, night,
+etc.).
+
+After inserting the record, if the object has a 'custnum' or 'prospectnum'
+field, an L<FS::cust_contact> or L<FS::prospect_contact> record will be
+created to link the contact to the customer. The following fields will also
+be included in that record, if they are set on the object:
+- classnum
+- comment
+- selfservice_access
+- invoice_dest
+
 =cut
 
 sub insert {
@@ -133,69 +155,76 @@ sub insert {
   $self->custnum('');
 
   my %link_hash = ();
-  for (qw( classnum comment selfservice_access )) {
+  for (qw( classnum comment selfservice_access invoice_dest message_dest)) {
     $link_hash{$_} = $self->get($_);
     $self->$_('');
   }
 
-  #look for an existing contact with this email address
+
+  ## check for an existing contact with this email address other than current customer
+  ## if found, just add that contact to cust_contact with link_hash credentials
+  ## as email can not be tied to two contacts.
+  my $no_new_contact;
   my $existing_contact = '';
+  my @contact_emails = ();
+  my %contact_nums = ();
+
   if ( $self->get('emailaddress') =~ /\S/ ) {
-  
-    my %existing_contact = ();
 
     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
  
-      my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
-        or next;
+      my $contact_email = qsearchs('contact_email', { emailaddress=>$email } );
+        unless ($contact_email) { push @contact_emails, $email; next; }
 
       my $contact = $contact_email->contact;
-      $existing_contact{ $contact->contactnum } = $contact;
-
-    }
+      if ($contact->contactnum eq $self->contactnum) {
+        push @contact_emails, $email;
+      }
+      else {
+        $contact_nums{$contact->contactnum} = '1';
+      }
 
-    if ( scalar( keys %existing_contact ) > 1 ) {
-      $dbh->rollback if $oldAutoCommit;
-      return 'Multiple email addresses specified '.
-             ' that already belong to separate contacts';
-    } elsif ( scalar( keys %existing_contact ) ) {
-      ($existing_contact) = values %existing_contact;
     }
 
-  }
-
-  if ( $existing_contact ) {
+    my $emails = join(' , ', @contact_emails);
+    $self->emailaddress($emails);
 
-    $self->$_($existing_contact->$_())
-      for qw( contactnum _password _password_encoding );
-    $self->SUPER::replace($existing_contact);
+    $no_new_contact = '1' unless $self->emailaddress;
 
-  } else {
+  }
 
-    my $error = $self->SUPER::insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
+  my $error;
+  $error = $self->SUPER::insert unless $no_new_contact;
 
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
   }
 
+  $contact_nums{$self->contactnum} = '1' if $self->contactnum;
+
   my $cust_contact = '';
+  # if $self->custnum was set, then the customer-specific properties
+  # (custnum, classnum, invoice_dest, selfservice_access, comment) are in
+  # pseudo-fields, and are now in %link_hash. otherwise, ignore all those
+  # fields.
   if ( $custnum ) {
-    my %hash = ( 'contactnum' => $self->contactnum,
-                 'custnum'    => $custnum,
-               );
-    $cust_contact =  qsearchs('cust_contact', \%hash )
-                  || new FS::cust_contact { %hash, %link_hash };
-    my $error = $cust_contact->custcontactnum ? $cust_contact->replace
+    foreach my $contactnum (keys %contact_nums) {
+      my %hash = ( 'contactnum' => $contactnum,
+                   'custnum'    => $custnum,
+                 );
+      $cust_contact =  qsearchs('cust_contact', \%hash )
+                    || new FS::cust_contact { %hash, %link_hash };
+      my $error = $cust_contact->custcontactnum ? $cust_contact->replace
                                               : $cust_contact->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
     }
   }
 
-  if ( $prospectnum ) {
+  if ( $prospectnum && !$no_new_contact) {
     my %hash = ( 'contactnum'  => $self->contactnum,
                  'prospectnum' => $prospectnum,
                );
@@ -210,6 +239,7 @@ sub insert {
     }
   }
 
+  unless ($no_new_contact) {
   foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
                         keys %{ $self->hashref } ) {
     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
@@ -228,6 +258,7 @@ sub insert {
       return $error;
     }
   }
+  }
 
   if ( $self->get('emailaddress') =~ /\S/ ) {
 
@@ -259,7 +290,10 @@ sub insert {
   }
 
   if (      $link_hash{'selfservice_access'} eq 'R'
-       or ( $link_hash{'selfservice_access'} && $cust_contact )
+       or ( $link_hash{'selfservice_access'}
+            && $cust_contact
+            && ! length($self->_password)
+          )
      )
   {
     my $error = $self->send_reset_email( queue=>1 );
@@ -269,6 +303,15 @@ sub insert {
     }
   }
 
+  if ( $self->get('password') ) {
+    my $error = $self->is_password_allowed($self->get('password'))
+          ||  $self->change_password($self->get('password'));
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
@@ -309,6 +352,8 @@ sub delete {
     }
   }
 
+  # if $self->custnum was set, then we're removing the contact from this
+  # customer.
   if ( $self->custnum ) {
     my $cust_contact = qsearchs('cust_contact', {
                          'contactnum'  => $self->contactnum,
@@ -350,7 +395,8 @@ sub delete {
     }
   }
 
-  my $error = $self->SUPER::delete;
+  my $error = $self->delete_password_history
+           || $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -391,36 +437,83 @@ sub replace {
   my $prospectnum = $self->prospectnum;
   $self->prospectnum('');
   my $custnum = $self->custnum;
-  $self->custnum('');
+  $self->custnum(''); $old->custnum(''); # remove because now stored cust_contact
 
   my %link_hash = ();
-  for (qw( classnum comment selfservice_access )) {
+  for (qw( classnum comment selfservice_access invoice_dest message_dest )) {
     $link_hash{$_} = $self->get($_);
+    $old->$_('');  ##remove values from old record, causes problem with history
     $self->$_('');
   }
 
+  ## check for an existing contact with this email address other than current customer
+  ## if found, just add that contact to cust_contact with link_hash credentials
+  ## as email can not be tied to two contacts.
+  my @contact_emails = ();
+  my %contact_nums = ();
+  $contact_nums{$self->contactnum} = '1' if $self->contactnum;
+  if ( $self->get('emailaddress') =~ /\S/ ) {
+
+    foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
+
+      my $contact_email = qsearchs('contact_email', { emailaddress=>$email } );
+        unless ($contact_email) { push @contact_emails, $email; next; }
+
+      my $contact = $contact_email->contact;
+      if ($contact->contactnum eq $self->contactnum) {
+        push @contact_emails, $email;
+      }
+      else {
+        $contact_nums{$contact->contactnum} = '1';
+      }
+
+    }
+
+    ## were all emails duplicates?  if so reset original emails
+    if (scalar @contact_emails < 1 && scalar (keys %contact_nums) > 1) {
+      foreach (qsearch('contact_email', {'contactnum' => $self->contactnum})) {
+        push @contact_emails, $_->emailaddress;
+      }
+    }
+
+    my $emails = join(' , ', @contact_emails);
+    $self->emailaddress($emails);
+
+  }
+
   my $error = $self->SUPER::replace($old);
+  if ( $old->_password ne $self->_password ) {
+    $error ||= $self->insert_password_history;
+  }
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
   my $cust_contact = '';
+  # if $self->custnum was set, then the customer-specific properties
+  # (custnum, classnum, invoice_dest, selfservice_access, comment) are in
+  # pseudo-fields, and are now in %link_hash. otherwise, ignore all those
+  # fields.
   if ( $custnum ) {
-    my %hash = ( 'contactnum' => $self->contactnum,
-                 'custnum'    => $custnum,
-               );
-    my $error;
-    if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
-      $cust_contact->$_($link_hash{$_}) for keys %link_hash;
-      $error = $cust_contact->replace;
-    } else {
-      $cust_contact = new FS::cust_contact { %hash, %link_hash };
-      $error = $cust_contact->insert;
-    }
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+
+    foreach my $contactnum (keys %contact_nums) {
+
+      my %hash = ( 'contactnum' => $contactnum,
+                   'custnum'    => $custnum,
+                 );
+      my $error;
+      if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
+        $cust_contact->$_($link_hash{$_}) for keys %link_hash;
+        $error = $cust_contact->replace;
+      } else {
+        $cust_contact = new FS::cust_contact { %hash, %link_hash };
+        $error = $cust_contact->insert;
+      }
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
     }
   }
 
@@ -452,8 +545,11 @@ sub replace {
              );
     my $contact_phone = qsearchs('contact_phone', \%cp);
 
+    my $pv = $self->get($pf);
+       $pv =~ s/\s//g;
+
     #if new value is empty, delete old entry
-    if (!$self->get($pf)) {
+    if (!$pv) {
       if ($contact_phone) {
         $error = $contact_phone->delete;
         if ( $error ) {
@@ -466,7 +562,7 @@ sub replace {
 
     $contact_phone ||= new FS::contact_phone \%cp;
 
-    my %cpd = _parse_phonestring( $self->get($pf) );
+    my %cpd = _parse_phonestring( $pv );
     $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
 
     my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
@@ -480,30 +576,38 @@ sub replace {
 
   if ( defined($self->hashref->{'emailaddress'}) ) {
 
-    #ineffecient but whatever, how many email addresses can there be?
-
+    my %contact_emails = ();
     foreach my $contact_email ( $self->contact_email ) {
-      my $error = $contact_email->delete;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
+      $contact_emails{$contact_email->emailaddress} = '1';
     }
 
     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
  
-      my $contact_email = new FS::contact_email {
-        'contactnum'   => $self->contactnum,
-        'emailaddress' => $email,
-      };
-      $error = $contact_email->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
+      unless ($contact_emails{$email}) {
+        my $contact_email = new FS::contact_email {
+          'contactnum'   => $self->contactnum,
+          'emailaddress' => $email,
+        };
+        $error = $contact_email->insert;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
       }
+      else { delete($contact_emails{$email}); }
 
     }
 
+    foreach my $contact_email ( $self->contact_email ) {
+      if ($contact_emails{$contact_email->emailaddress}) {
+        my $error = $contact_email->delete;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+      }
+    }
+
   }
 
   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
@@ -532,6 +636,15 @@ sub replace {
     }
   }
 
+  if ( $self->get('password') ) {
+    my $error = $self->is_password_allowed($self->get('password'))
+          ||  $self->change_password($self->get('password'));
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
@@ -618,7 +731,7 @@ and replace methods.
 sub check {
   my $self = shift;
 
-  if ( $self->selfservice_access eq 'R' ) {
+  if ( $self->selfservice_access eq 'R' || $self->selfservice_access eq 'P' ) {
     $self->selfservice_access('Y');
     $self->_resend('Y');
   }
@@ -708,20 +821,58 @@ sub firstlast {
 
 =item by_selfservice_email EMAILADDRESS
 
-Alternate search constructor (class method).  Given an email address,
-returns the contact for that address, or the empty string if no contact
-has that email address.
+Alternate search constructor (class method).  Given an email address, returns
+the contact for that address. If that contact doesn't have selfservice access,
+or there isn't one, returns the empty string.
 
 =cut
 
 sub by_selfservice_email {
-  my($class, $email) = @_;
+  my($class, $email, $case_insensitive) = @_;
+
+  my $email_search = "emailaddress = '".$email."'";
+  $email_search = "LOWER(emailaddress) = LOWER('".$email."')" if $case_insensitive;
+
+  my $contact_email = qsearchs({
+    'table'     => 'contact_email',
+    'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
+    'extra_sql' => "
+      WHERE $email_search
+      AND ( contact.disabled IS NULL )
+      AND EXISTS ( SELECT 1 FROM cust_contact
+                     WHERE contact.contactnum = cust_contact.contactnum
+                       AND cust_contact.selfservice_access = 'Y'
+                 )
+    ",
+  }) or return '';
+
+  $contact_email->contact;
+
+}
+
+=item by_selfservice_email_custnum EMAILADDRESS, CUSTNUM
+
+Alternate search constructor (class method).  Given an email address and custnum, returns
+the contact for that address and custnum. If that contact doesn't have selfservice access,
+or there isn't one, returns the empty string.
+
+=cut
+
+sub by_selfservice_email_custnum {
+  my($class, $email, $custnum) = @_;
 
   my $contact_email = qsearchs({
     'table'     => 'contact_email',
     'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
     'hashref'   => { 'emailaddress' => $email, },
-    'extra_sql' => " AND ( disabled IS NULL OR disabled = '' )",
+    'extra_sql' => "
+      AND ( contact.disabled IS NULL )
+      AND EXISTS ( SELECT 1 FROM cust_contact
+                     WHERE contact.contactnum = cust_contact.contactnum
+                       AND cust_contact.selfservice_access = 'Y'
+                       AND cust_contact.custnum = $custnum
+                 )
+    ",
   }) or return '';
 
   $contact_email->contact;
@@ -750,7 +901,7 @@ sub authenticate_password {
 
     $hash eq $check_hash;
 
-  } else { 
+  } else {
 
     return 0 if $self->_password eq '';
 
@@ -760,9 +911,22 @@ sub authenticate_password {
 
 }
 
+=item change_password NEW_PASSWORD
+
+Changes the contact's selfservice access password to NEW_PASSWORD. This does
+not check password policy rules (see C<is_password_allowed>) and will return
+an error only if editing the record fails for some reason.
+
+If NEW_PASSWORD is the same as the existing password, this does nothing.
+
+=cut
+
 sub change_password {
   my($self, $new_password) = @_;
 
+  # do nothing if the password is unchanged
+  return if $self->authenticate_password($new_password);
+
   $self->change_password_fields( $new_password );
 
   $self->replace;
@@ -808,7 +972,10 @@ sub send_reset_email {
     'svcnum'     => $opt{'svcnum'},
   };
 
-  my $timeout = '24 hours'; #?
+  
+  my $conf = new FS::Conf;
+  my $timeout =
+    ($conf->config('selfservice-password_reset_hours') || 24 ). ' hours';
 
   my $reset_session_id;
   do {
@@ -820,8 +987,6 @@ sub send_reset_email {
 
   #email it
 
-  my $conf = new FS::Conf;
-
   my $cust_main = '';
   my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
   $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
@@ -829,8 +994,9 @@ sub send_reset_email {
   my $agentnum = $cust_main ? $cust_main->agentnum : '';
   my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
   #die "selfservice-password_reset_msgnum unset" unless $msgnum;
-  return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
+  return "selfservice-password_reset_msgnum unset" unless $msgnum;
   my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
+  return "selfservice-password_reset_msgnum cannot be loaded" unless $msg_template;
   my %msg_template = (
     'to'            => join(',', map $_->emailaddress, @contact_email ),
     'cust_main'     => $cust_main,
@@ -840,11 +1006,14 @@ sub send_reset_email {
 
   if ( $opt{'queue'} ) { #or should queueing just be the default?
 
+    my $cust_msg = $msg_template->prepare( %msg_template );
+    my $error = $cust_msg->insert;
+    return $error if $error;
     my $queue = new FS::queue {
-      'job'     => 'FS::Misc::process_send_email',
+      'job'     => 'FS::cust_msg::process_send',
       'custnum' => $cust_main ? $cust_main->custnum : '',
     };
-    $queue->insert( $msg_template->prepare( %msg_template ) );
+    $queue->insert( $cust_msg->custmsgnum );
 
   } else {
 
@@ -876,6 +1045,7 @@ sub cgi_contact_fields {
 
   my @contact_fields = qw(
     classnum first last title comment emailaddress selfservice_access
+    invoice_dest message_dest password
   );
 
   push @contact_fields, 'phonetypenum'. $_->phonetypenum
@@ -889,14 +1059,50 @@ use FS::upgrade_journal;
 sub _upgrade_data { #class method
   my ($class, %opts) = @_;
 
-  unless ( FS::upgrade_journal->is_done('contact__DUPEMAIL') ) {
+  # before anything else, migrate contact.custnum to cust_contact records
+  unless ( FS::upgrade_journal->is_done('contact_invoice_dest') ) {
+
+    local($skip_fuzzyfiles) = 1;
 
     foreach my $contact (qsearch('contact', {})) {
       my $error = $contact->replace;
       die $error if $error;
     }
 
-    FS::upgrade_journal->set_done('contact__DUPEMAIL');
+    FS::upgrade_journal->set_done('contact_invoice_dest');
+  }
+
+
+  # always migrate cust_main_invoice records over
+  local $FS::cust_main::import = 1; # override require_phone and such
+  my $search = FS::Cursor->new('cust_main_invoice', {});
+  my %custnum_dest;
+  while (my $cust_main_invoice = $search->fetch) {
+    my $custnum = $cust_main_invoice->custnum;
+    my $dest = $cust_main_invoice->dest;
+    my $cust_main = $cust_main_invoice->cust_main;
+
+    if ( $dest =~ /^\d+$/ ) {
+      my $svc_acct = FS::svc_acct->by_key($dest);
+      die "custnum $custnum, invoice destination svcnum $svc_acct does not exist\n"
+        if !$svc_acct;
+      $dest = $svc_acct->email;
+    }
+    push @{ $custnum_dest{$custnum} ||= [] }, $dest;
+
+    my $error = $cust_main_invoice->delete;
+    if ( $error ) {
+      die "custnum $custnum, cleaning up cust_main_invoice: $error\n";
+    }
+  }
+
+  foreach my $custnum (keys %custnum_dest) {
+    my $dests = $custnum_dest{$custnum};
+    my $cust_main = FS::cust_main->by_key($custnum);
+    my $error = $cust_main->replace( invoicing_list => $dests );
+    if ( $error ) {
+      die "custnum $custnum, creating contact: $error\n";
+    }
   }
 
 }
@@ -912,4 +1118,3 @@ L<FS::Record>, schema.html from the base documentation.
 =cut
 
 1;
-