false,'days'=>array_fill_keys(self::CATEGORIES,null)],(array)get_option('abece_booking_privacy_retention',[]));} public function save(array $data):array { BookingValidator::fields($data,['automatic','days','confirmed']);$automatic=CatalogValidator::boolean($data['automatic']??false,'automatic');$days=$data['days']??[];if(!is_array($days)||array_diff(array_keys($days),self::CATEGORIES))CatalogValidator::invalid('days',__('Choose valid retention categories.','abece-booking')); foreach(self::CATEGORIES as $category)$days[$category]=CatalogValidator::integer($days[$category]??null,$category,1,36500,true); if($automatic&&(($data['confirmed']??false)!==true||!array_filter($days)))CatalogValidator::invalid('automatic',__('Explicitly confirm the retention configuration before enabling automatic cleanup.','abece-booking')); update_option('abece_booking_privacy_retention',['automatic'=>$automatic,'days'=>$days],false);wp_clear_scheduled_hook('abece_booking_privacy_retention');if($automatic)wp_schedule_event(time()+DAY_IN_SECONDS,'daily','abece_booking_privacy_retention');return $this->settings(); } public function review(string $key,array $data):array { BookingValidator::fields($data,['reason','review_days','confirmed']);if(($data['confirmed']??false)!==true)throw new \ABeCe\Booking\Domain\CatalogException('abece_booking_privacy_confirmation',__('Confirm the individual retention review.','abece-booking'),400); $reason=CatalogValidator::choice($data['reason']??null,'reason',['active_operations','financial_records','legal_claim','identity_review']);$days=CatalogValidator::integer($data['review_days']??90,'review_days',1,3650); return $this->r->exclusive(function()use($key,$reason,$days){$reviews=(array)get_option('abece_booking_privacy_reviews',[]);if(!isset($reviews[$key]))throw new \ABeCe\Booking\Domain\CatalogException('abece_booking_not_found',__('The selected record does not exist.','abece-booking'),404);$reviews[$key]['operator_reason']=$reason;$reviews[$key]['reviewed_by']=get_current_user_id();$reviews[$key]['reviewed_at']=time();$reviews[$key]['review_after']=time()+$days*DAY_IN_SECONDS;update_option('abece_booking_privacy_reviews',$reviews,false);return ['reviewed'=>true];}); } public function minimization(array $data):array {BookingValidator::fields($data,['require_phone']);$required=CatalogValidator::boolean($data['require_phone']??null,'require_phone');update_option('abece_booking_require_phone',(int)$required,false);return ['require_phone'=>(bool)get_option('abece_booking_require_phone',true)];} private function selection(string $category,int $days):array { $before=current_datetime()->modify('-'.$days.' days')->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); return match($category){ 'historical_bookings'=>['bookings',"status IN ('completed','no_show') AND ends_at<%s AND (customer_user_id IS NOT NULL OR customer_member_id IS NOT NULL OR external_contact_id IS NOT NULL OR customer_note IS NOT NULL)",[$before]], 'cancelled_bookings'=>['bookings',"status IN ('cancelled','voided') AND ends_at<%s AND (customer_user_id IS NOT NULL OR customer_member_id IS NOT NULL OR external_contact_id IS NOT NULL OR customer_note IS NOT NULL)",[$before]], 'inactive_contacts'=>['external_contacts','is_active=0 AND COALESCE(last_used_at,updated_at)<%s',[$before]], 'inactive_members'=>['members',"is_active=0 AND updated_at<%s AND (email IS NOT NULL OR phone IS NOT NULL OR date_of_birth IS NOT NULL OR member_number IS NOT NULL OR wp_user_id IS NOT NULL)",[$before]], 'booking_notes'=>['bookings',"ends_at<%s AND status IN ('completed','cancelled','voided') AND customer_note IS NOT NULL",[$before]], 'notification_logs'=>['notifications',"updated_at<%s AND status IN ('sent','failed') AND recipient<>'privacy-erased@example.invalid'",[$before]], }; } public function run(bool $apply=false,bool $cron=false):array { $settings=$this->settings();if($cron&&!$settings['automatic'])return ['disabled'=>true]; $operation=function()use($settings,$apply){$result=['applied'=>$apply,'categories'=>[],'removed'=>0,'retained'=>0,'at'=>time()];$cursor=(array)get_option('abece_booking_privacy_retention_cursor',[]); foreach(self::CATEGORIES as $category){$days=$settings['days'][$category]??null;if(!$days)continue;[$table,$where,$args]=$this->selection($category,$days);$total=$this->r->count($table,$where,$args);$last=$apply?(int)($cursor[$category]??0):0;$rows=$this->r->rows($table,'('.$where.') AND id>%d',array_merge($args,[$last]),'id',25);if(!$rows&&$apply){$cursor[$category]=0;$rows=$this->r->rows($table,$where,$args,'id',25);} $stats=['candidates'=>$total,'checked'=>count($rows),'eligible'=>0,'retained'=>0,'reasons'=>[]]; foreach($rows as $row){$id=(int)$row['id'];if($category==='notification_logs'){$n=$this->r->rows('notifications','id=%d',[$id])[0];$b=$n['booking_id']?$this->r->rows('bookings','id=%d',[(int)$n['booking_id']])[0]??null:null;$holds=$b&&($b['final_amount']!=0||$b['ends_at']>=current_time('mysql',true))?['financial']:[];$decision=['removed'=>false,'reasons'=>$holds];if($apply&&!$holds)$decision['removed']=$this->r->update('notifications',$id,['recipient'=>'privacy-erased@example.invalid','subject'=>null,'last_error'=>null,'user_id'=>null]);} else $decision=$this->eraser->retentionCandidate($table,$id,$apply); if(!$decision['reasons'])$stats['eligible']++;else{$stats['retained']++;foreach($decision['reasons'] as $code)$stats['reasons'][$code]=($stats['reasons'][$code]??0)+1;}$result['removed']+=(int)$decision['removed'];if($apply)$cursor[$category]=$id; }$result['retained']+=$stats['retained'];$result['categories'][$category]=$stats; } if($apply){update_option('abece_booking_privacy_retention_cursor',$cursor,false);$audit=(array)get_option('abece_booking_privacy_cleanup_audit',[]);$audit[]=['at'=>time(),'actor'=>get_current_user_id(),'removed'=>$result['removed'],'retained'=>$result['retained']];update_option('abece_booking_privacy_cleanup_audit',array_slice($audit,-100),false);}return $result; }; return $apply?$this->r->exclusive($operation):$operation(); } }