remove deleted contacts from packages, RT#28949
[freeside.git] / FS / FS / contact.pm
1 package FS::contact;
2 use base qw( FS::Record );
3
4 use strict;
5 use Scalar::Util qw( blessed );
6 use FS::Record qw( qsearchs dbh ); # qw( qsearch qsearchs dbh );
7 use FS::contact_phone;
8 use FS::contact_email;
9 use FS::queue;
10
11 =head1 NAME
12
13 FS::contact - Object methods for contact records
14
15 =head1 SYNOPSIS
16
17   use FS::contact;
18
19   $record = new FS::contact \%hash;
20   $record = new FS::contact { 'column' => 'value' };
21
22   $error = $record->insert;
23
24   $error = $new_record->replace($old_record);
25
26   $error = $record->delete;
27
28   $error = $record->check;
29
30 =head1 DESCRIPTION
31
32 An FS::contact object represents an example.  FS::contact inherits from
33 FS::Record.  The following fields are currently supported:
34
35 =over 4
36
37 =item contactnum
38
39 primary key
40
41 =item prospectnum
42
43 prospectnum
44
45 =item custnum
46
47 custnum
48
49 =item locationnum
50
51 locationnum
52
53 =item last
54
55 last
56
57 =item first
58
59 first
60
61 =item title
62
63 title
64
65 =item comment
66
67 comment
68
69 =item selfservice_access
70
71 empty or Y
72
73 =item _password
74
75 =item _password_encoding
76
77 empty or bcrypt
78
79 =item disabled
80
81 disabled
82
83
84 =back
85
86 =head1 METHODS
87
88 =over 4
89
90 =item new HASHREF
91
92 Creates a new example.  To add the example to the database, see L<"insert">.
93
94 Note that this stores the hash reference, not a distinct copy of the hash it
95 points to.  You can ask the object for a copy with the I<hash> method.
96
97 =cut
98
99 # the new method can be inherited from FS::Record, if a table method is defined
100
101 sub table { 'contact'; }
102
103 =item insert
104
105 Adds this record to the database.  If there is an error, returns the error,
106 otherwise returns false.
107
108 =cut
109
110 sub insert {
111   my $self = shift;
112
113   local $SIG{INT} = 'IGNORE';
114   local $SIG{QUIT} = 'IGNORE';
115   local $SIG{TERM} = 'IGNORE';
116   local $SIG{TSTP} = 'IGNORE';
117   local $SIG{PIPE} = 'IGNORE';
118
119   my $oldAutoCommit = $FS::UID::AutoCommit;
120   local $FS::UID::AutoCommit = 0;
121   my $dbh = dbh;
122
123   my $error = $self->SUPER::insert;
124   if ( $error ) {
125     $dbh->rollback if $oldAutoCommit;
126     return $error;
127   }
128
129   foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
130                         keys %{ $self->hashref } ) {
131     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
132     my $phonetypenum = $1;
133
134     my $contact_phone = new FS::contact_phone {
135       'contactnum' => $self->contactnum,
136       'phonetypenum' => $phonetypenum,
137       _parse_phonestring( $self->get($pf) ),
138     };
139     $error = $contact_phone->insert;
140     if ( $error ) {
141       $dbh->rollback if $oldAutoCommit;
142       return $error;
143     }
144   }
145
146   if ( $self->get('emailaddress') =~ /\S/ ) {
147
148     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
149  
150       my $contact_email = new FS::contact_email {
151         'contactnum'   => $self->contactnum,
152         'emailaddress' => $email,
153       };
154       $error = $contact_email->insert;
155       if ( $error ) {
156         $dbh->rollback if $oldAutoCommit;
157         return $error;
158       }
159
160     }
161
162   }
163
164   #unless ( $import || $skip_fuzzyfiles ) {
165     #warn "  queueing fuzzyfiles update\n"
166     #  if $DEBUG > 1;
167     $error = $self->queue_fuzzyfiles_update;
168     if ( $error ) {
169       $dbh->rollback if $oldAutoCommit;
170       return "updating fuzzy search cache: $error";
171     }
172   #}
173
174   if ( $self->selfservice_access ) {
175     my $error = $self->send_reset_email( queue=>1 );
176     if ( $error ) {
177       $dbh->rollback if $oldAutoCommit;
178       return $error;
179     }
180   }
181
182   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
183
184   '';
185
186 }
187
188 =item delete
189
190 Delete this record from the database.
191
192 =cut
193
194 # the delete method can be inherited from FS::Record
195
196 sub delete {
197   my $self = shift;
198
199   local $SIG{HUP} = 'IGNORE';
200   local $SIG{INT} = 'IGNORE';
201   local $SIG{QUIT} = 'IGNORE';
202   local $SIG{TERM} = 'IGNORE';
203   local $SIG{TSTP} = 'IGNORE';
204   local $SIG{PIPE} = 'IGNORE';
205
206   my $oldAutoCommit = $FS::UID::AutoCommit;
207   local $FS::UID::AutoCommit = 0;
208   my $dbh = dbh;
209
210   foreach my $cust_pkg ( $self->cust_pkg ) {
211     $cust_pkg->contactnum('');
212     my $error = $cust_pkg->replace;
213     if ( $error ) {
214       $dbh->rollback if $oldAutoCommit;
215       return $error;
216     }
217   }
218
219   foreach my $object ( $self->contact_phone, $self->contact_email ) {
220     my $error = $object->delete;
221     if ( $error ) {
222       $dbh->rollback if $oldAutoCommit;
223       return $error;
224     }
225   }
226
227   my $error = $self->SUPER::delete;
228   if ( $error ) {
229     $dbh->rollback if $oldAutoCommit;
230     return $error;
231   }
232
233   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
234   '';
235
236 }
237
238 =item replace OLD_RECORD
239
240 Replaces the OLD_RECORD with this one in the database.  If there is an error,
241 returns the error, otherwise returns false.
242
243 =cut
244
245 sub replace {
246   my $self = shift;
247
248   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
249               ? shift
250               : $self->replace_old;
251
252   $self->$_( $self->$_ || $old->$_ ) for qw( _password _password_encoding );
253
254   local $SIG{INT} = 'IGNORE';
255   local $SIG{QUIT} = 'IGNORE';
256   local $SIG{TERM} = 'IGNORE';
257   local $SIG{TSTP} = 'IGNORE';
258   local $SIG{PIPE} = 'IGNORE';
259
260   my $oldAutoCommit = $FS::UID::AutoCommit;
261   local $FS::UID::AutoCommit = 0;
262   my $dbh = dbh;
263
264   my $error = $self->SUPER::replace($old);
265   if ( $error ) {
266     $dbh->rollback if $oldAutoCommit;
267     return $error;
268   }
269
270   foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) }
271                         keys %{ $self->hashref } ) {
272     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
273     my $phonetypenum = $1;
274
275     my %cp = ( 'contactnum'   => $self->contactnum,
276                'phonetypenum' => $phonetypenum,
277              );
278     my $contact_phone = qsearchs('contact_phone', \%cp)
279                         || new FS::contact_phone   \%cp;
280
281     my %cpd = _parse_phonestring( $self->get($pf) );
282     $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
283
284     my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
285
286     $error = $contact_phone->$method;
287     if ( $error ) {
288       $dbh->rollback if $oldAutoCommit;
289       return $error;
290     }
291   }
292
293   if ( defined($self->hashref->{'emailaddress'}) ) {
294
295     #ineffecient but whatever, how many email addresses can there be?
296
297     foreach my $contact_email ( $self->contact_email ) {
298       my $error = $contact_email->delete;
299       if ( $error ) {
300         $dbh->rollback if $oldAutoCommit;
301         return $error;
302       }
303     }
304
305     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
306  
307       my $contact_email = new FS::contact_email {
308         'contactnum'   => $self->contactnum,
309         'emailaddress' => $email,
310       };
311       $error = $contact_email->insert;
312       if ( $error ) {
313         $dbh->rollback if $oldAutoCommit;
314         return $error;
315       }
316
317     }
318
319   }
320
321   #unless ( $import || $skip_fuzzyfiles ) {
322     #warn "  queueing fuzzyfiles update\n"
323     #  if $DEBUG > 1;
324     $error = $self->queue_fuzzyfiles_update;
325     if ( $error ) {
326       $dbh->rollback if $oldAutoCommit;
327       return "updating fuzzy search cache: $error";
328     }
329   #}
330
331   if (    ( $old->selfservice_access eq '' && $self->selfservice_access
332               && ! $self->_password
333           )
334        || $self->_resend()
335      )
336   {
337     my $error = $self->send_reset_email( queue=>1 );
338     if ( $error ) {
339       $dbh->rollback if $oldAutoCommit;
340       return $error;
341     }
342   }
343
344   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
345
346   '';
347
348 }
349
350 #i probably belong in contact_phone.pm
351 sub _parse_phonestring {
352   my $value = shift;
353
354   my($countrycode, $extension) = ('1', '');
355
356   #countrycode
357   if ( $value =~ s/^\s*\+\s*(\d+)// ) {
358     $countrycode = $1;
359   } else {
360     $value =~ s/^\s*1//;
361   }
362   #extension
363   if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
364      $extension = $2;
365   }
366
367   ( 'countrycode' => $countrycode,
368     'phonenum'    => $value,
369     'extension'   => $extension,
370   );
371 }
372
373 =item queue_fuzzyfiles_update
374
375 Used by insert & replace to update the fuzzy search cache
376
377 =cut
378
379 use FS::cust_main::Search;
380 sub queue_fuzzyfiles_update {
381   my $self = shift;
382
383   local $SIG{HUP} = 'IGNORE';
384   local $SIG{INT} = 'IGNORE';
385   local $SIG{QUIT} = 'IGNORE';
386   local $SIG{TERM} = 'IGNORE';
387   local $SIG{TSTP} = 'IGNORE';
388   local $SIG{PIPE} = 'IGNORE';
389
390   my $oldAutoCommit = $FS::UID::AutoCommit;
391   local $FS::UID::AutoCommit = 0;
392   my $dbh = dbh;
393
394   foreach my $field ( 'first', 'last' ) {
395     my $queue = new FS::queue { 
396       'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
397     };
398     my @args = "contact.$field", $self->get($field);
399     my $error = $queue->insert( @args );
400     if ( $error ) {
401       $dbh->rollback if $oldAutoCommit;
402       return "queueing job (transaction rolled back): $error";
403     }
404   }
405
406   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
407   '';
408
409 }
410
411 =item check
412
413 Checks all fields to make sure this is a valid example.  If there is
414 an error, returns the error, otherwise returns false.  Called by the insert
415 and replace methods.
416
417 =cut
418
419 # the check method should currently be supplied - FS::Record contains some
420 # data checking routines
421
422 sub check {
423   my $self = shift;
424
425   if ( $self->selfservice_access eq 'R' ) {
426     $self->selfservice_access('Y');
427     $self->_resend('Y');
428   }
429
430   my $error = 
431     $self->ut_numbern('contactnum')
432     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
433     || $self->ut_foreign_keyn('custnum',     'cust_main',     'custnum')
434     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
435     || $self->ut_foreign_keyn('classnum',    'contact_class', 'classnum')
436     || $self->ut_namen('last')
437     || $self->ut_namen('first')
438     || $self->ut_textn('title')
439     || $self->ut_textn('comment')
440     || $self->ut_enum('selfservice_access', [ '', 'Y' ])
441     || $self->ut_textn('_password')
442     || $self->ut_enum('_password_encoding', [ '', 'bcrypt'])
443     || $self->ut_enum('disabled', [ '', 'Y' ])
444   ;
445   return $error if $error;
446
447   return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
448   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
449
450   return "One of first name, last name, or title must have a value"
451     if ! grep $self->$_(), qw( first last title);
452
453   $self->SUPER::check;
454 }
455
456 sub line {
457   my $self = shift;
458   my $data = $self->first. ' '. $self->last;
459   $data .= ', '. $self->title
460     if $self->title;
461   $data .= ' ('. $self->comment. ')'
462     if $self->comment;
463   $data;
464 }
465
466 sub contact_classname {
467   my $self = shift;
468   my $contact_class = $self->contact_class or return '';
469   $contact_class->classname;
470 }
471
472 sub by_selfservice_email {
473   my($class, $email) = @_;
474
475   my $contact_email = qsearchs({
476     'table'     => 'contact_email',
477     'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
478     'hashref'   => { 'emailaddress' => $email, },
479     'extra_sql' => " AND selfservice_access = 'Y' ".
480                    " AND ( disabled IS NULL OR disabled = '' )",
481   }) or return '';
482
483   $contact_email->contact;
484
485 }
486
487 #these three functions are very much false laziness w/FS/FS/Auth/internal.pm
488 # and should maybe be libraried in some way for other password needs
489
490 use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64);
491
492 sub authenticate_password {
493   my($self, $check_password) = @_;
494
495   if ( $self->_password_encoding eq 'bcrypt' ) {
496
497     my( $cost, $salt, $hash ) = split(',', $self->_password);
498
499     my $check_hash = en_base64( bcrypt_hash( { key_nul => 1,
500                                                cost    => $cost,
501                                                salt    => de_base64($salt),
502                                              },
503                                              $check_password
504                                            )
505                               );
506
507     $hash eq $check_hash;
508
509   } else { 
510
511     return 0 if $self->_password eq '';
512
513     $self->_password eq $check_password;
514
515   }
516
517 }
518
519 sub change_password {
520   my($self, $new_password) = @_;
521
522   $self->change_password_fields( $new_password );
523
524   $self->replace;
525
526 }
527
528 sub change_password_fields {
529   my($self, $new_password) = @_;
530
531   $self->_password_encoding('bcrypt');
532
533   my $cost = 8;
534
535   my $salt = pack( 'C*', map int(rand(256)), 1..16 );
536
537   my $hash = bcrypt_hash( { key_nul => 1,
538                             cost    => $cost,
539                             salt    => $salt,
540                           },
541                           $new_password,
542                         );
543
544   $self->_password(
545     join(',', $cost, en_base64($salt), en_base64($hash) )
546   );
547
548 }
549
550 # end of false laziness w/FS/FS/Auth/internal.pm
551
552
553 #false laziness w/ClientAPI/MyAccount/reset_passwd
554 use Digest::SHA qw(sha512_hex);
555 use FS::Conf;
556 use FS::ClientAPI_SessionCache;
557 sub send_reset_email {
558   my( $self, %opt ) = @_;
559
560   my @contact_email = $self->contact_email or return '';
561
562   my $reset_session = {
563     'contactnum' => $self->contactnum,
564     'svcnum'     => $opt{'svcnum'},
565   };
566
567   my $timeout = '24 hours'; #?
568
569   my $reset_session_id;
570   do {
571     $reset_session_id = sha512_hex(time(). {}. rand(). $$)
572   } until ( ! defined $self->myaccount_cache->get("reset_passwd_$reset_session_id") );
573     #just in case
574
575   $self->myaccount_cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
576
577   #email it
578
579   my $conf = new FS::Conf;
580
581   my $cust_main = $self->cust_main
582     or die "no customer"; #reset a password for a prospect contact?  someday
583
584   my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
585   #die "selfservice-password_reset_msgnum unset" unless $msgnum;
586   return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
587   my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
588   my %msg_template = (
589     'to'            => join(',', map $_->emailaddress, @contact_email ),
590     'cust_main'     => $cust_main,
591     'object'        => $self,
592     'substitutions' => { 'session_id' => $reset_session_id }
593   );
594
595   if ( $opt{'queue'} ) { #or should queueing just be the default?
596
597     my $queue = new FS::queue {
598       'job'     => 'FS::Misc::process_send_email',
599       'custnum' => $cust_main->custnum,
600     };
601     $queue->insert( $msg_template->prepare( %msg_template ) );
602
603   } else {
604
605     $msg_template->send( %msg_template );
606
607   }
608
609 }
610
611 use vars qw( $myaccount_cache );
612 sub myaccount_cache {
613   #my $class = shift;
614   $myaccount_cache ||= new FS::ClientAPI_SessionCache( {
615                          'namespace' => 'FS::ClientAPI::MyAccount',
616                        } );
617 }
618
619 =back
620
621 =head1 BUGS
622
623 =head1 SEE ALSO
624
625 L<FS::Record>, schema.html from the base documentation.
626
627 =cut
628
629 1;
630