customers = new CustomersRepository();
$this->appointments = new AppointmentsRepository();
$this->services = new ServicesRepository();
$this->staff = new StaffRepository();
$this->locations = new LocationsRepository();
$this->activity = new ActivityLogRepository();
}
public function register(): void {
add_action( 'admin_init', array( $this, 'add_privacy_policy_content' ) );
add_action( 'admin_post_abientot_export_services', array( $this, 'export_services_csv' ) );
add_action( 'admin_post_abientot_export_settings', array( $this, 'export_settings_json' ) );
add_action( 'admin_post_abientot_import_settings', array( $this, 'import_settings_json' ) );
add_action( 'admin_post_abientot_anonymize_customer', array( $this, 'anonymize_customer' ) );
add_action( 'admin_post_abientot_delete_customer', array( $this, 'delete_customer' ) );
add_action( 'admin_post_abientot_run_repair_checks', array( $this, 'run_repair_checks' ) );
}
public function add_privacy_policy_content(): void {
if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
return;
}
wp_add_privacy_policy_content( 'Abientot', wp_kses_post( wpautop( $this->privacy_policy_text() ) ) );
}
public function render_privacy_tools(): void {
$customer_id = (int) Request::view( 'customer_id', 'int' );
$customer = $customer_id > 0 ? $this->customers->find( $customer_id ) : null;
$this->notice();
?>
customers->appointment_count( $customer_id ) ) ); ?>
notice();
$status = $this->system_status();
?>
authorize( 'abientot_manage_services' );
$this->send_csv_headers( 'abientot-services-' . gmdate( 'Y-m-d' ) . '.csv' );
$out = fopen( 'php://output', 'w' );
if ( false === $out ) {
exit;
}
fputcsv( $out, array( 'ID', 'Name', 'Category', 'Duration', 'Price', 'Currency', 'Buffer Before', 'Buffer After', 'Status', 'Visibility', 'Sort Order', 'Created', 'Updated' ) );
foreach ( $this->services->list_for_admin() as $row ) {
fputcsv(
$out,
array(
$row['id'],
$this->csv_cell( (string) $row['name'] ),
$this->csv_cell( (string) ( $row['category_name'] ?? '' ) ),
(int) $row['duration_minutes'],
$this->format_price( (int) $row['price_cents'] ),
$this->csv_cell( (string) $row['currency'] ),
(int) $row['buffer_before_minutes'],
(int) $row['buffer_after_minutes'],
$this->csv_cell( (string) $row['status'] ),
$this->csv_cell( (string) $row['visibility'] ),
(int) $row['sort_order'],
$this->csv_cell( (string) $row['created_at'] ),
$this->csv_cell( (string) $row['updated_at'] ),
)
);
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the authorized php://output CSV response stream; WP_Filesystem does not manage response streams.
fclose( $out );
exit;
}
public function export_settings_json(): void {
check_admin_referer( 'abientot_export_settings' );
$this->authorize( 'abientot_manage_settings' );
nocache_headers();
header( 'Content-Type: application/json; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=abientot-settings-' . gmdate( 'Y-m-d' ) . '.json' );
echo wp_json_encode(
array(
'plugin' => 'abientot',
'version' => ABIENTOT_VERSION,
'exported_at' => gmdate( 'c' ),
'settings' => Settings::values(),
)
);
exit;
}
public function import_settings_json(): void {
check_admin_referer( 'abientot_import_settings' );
$this->authorize( 'abientot_manage_settings' );
// PHP supplies this path; validate upload provenance rather than modifying its filename.
$upload = Request::files( 'abientot_import_settings', 'settings_file' );
$upload_ok = isset( $upload['error'] ) && UPLOAD_ERR_OK === $upload['error'];
$upload_path = $upload_ok && isset( $upload['tmp_name'] ) && is_string( $upload['tmp_name'] ) ? $upload['tmp_name'] : '';
if ( '' === $upload_path || ! is_uploaded_file( $upload_path ) ) {
$this->redirect_settings( 'privacy-data', 'import-invalid' );
}
$upload_size = filesize( $upload_path );
if ( false === $upload_size || $upload_size > 524288 ) {
$this->redirect_settings( 'privacy-data', 'import-too-large' );
}
$contents = file_get_contents( $upload_path );
$decoded = is_string( $contents ) ? json_decode( $contents, true ) : null;
if ( ! is_array( $decoded ) || ! isset( $decoded['settings'] ) || ! is_array( $decoded['settings'] ) ) {
$this->redirect_settings( 'privacy-data', 'import-invalid' );
}
update_option( Settings::OPTION_NAME, ( new Settings() )->sanitize_import( $decoded['settings'] ), false );
$this->redirect_settings( 'privacy-data', 'imported' );
}
public function anonymize_customer(): void {
check_admin_referer( 'abientot_anonymize_customer' );
$this->authorize( 'abientot_manage_customers' );
$customer_id = Input::query( 'abientot_anonymize_customer' )->int( 'customer_id' );
if ( ! $this->customers->anonymize( $customer_id ) ) {
$this->redirect_settings( 'privacy-data', 'customer-invalid' );
}
$this->activity->insert(
array(
'object_type' => 'customer',
'object_id' => $customer_id,
'event' => 'customer_anonymized',
'actor_type' => 'user',
'actor_id' => get_current_user_id(),
'message' => __( 'Customer personal details anonymized by an administrator.', 'abientot' ),
'context_json' => null,
'created_at' => gmdate( 'Y-m-d H:i:s' ),
)
);
$this->redirect_settings( 'privacy-data', 'customer-anonymized' );
}
public function delete_customer(): void {
check_admin_referer( 'abientot_delete_customer' );
$this->authorize( 'abientot_manage_customers' );
$customer_id = Input::query( 'abientot_delete_customer' )->int( 'customer_id' );
if ( ! $this->customers->delete_if_no_appointments( $customer_id ) ) {
$this->redirect_settings( 'privacy-data', 'customer-delete-blocked' );
}
$this->redirect_settings( 'privacy-data', 'customer-deleted' );
}
public function run_repair_checks(): void {
check_admin_referer( 'abientot_run_repair_checks' );
$this->authorize( 'abientot_manage_settings' );
if ( ! ( new Migrator() )->run() ) {
$this->redirect_settings( 'advanced', 'repair-failed' );
}
( new Seeder() )->seed();
$this->redirect_settings( 'advanced', 'repair-complete' );
}
private function system_status(): array {
$settings = Settings::values();
return array_merge(
array(
__( 'Plugin version', 'abientot' ) => ABIENTOT_VERSION,
__( 'Schema version', 'abientot' ) => (string) get_option( 'abientot_db_version', '' ),
__( 'PHP version', 'abientot' ) => PHP_VERSION,
__( 'WordPress version', 'abientot' ) => get_bloginfo( 'version' ),
__( 'Business timezone', 'abientot' ) => (string) $settings['business_timezone'],
__( 'Delete data on uninstall', 'abientot' ) => empty( $settings['delete_data_on_uninstall'] ) ? __( 'No', 'abientot' ) : __( 'Yes', 'abientot' ),
__( 'Default staff', 'abientot' ) => $this->staff->default_staff() ? __( 'Present', 'abientot' ) : __( 'Missing', 'abientot' ),
__( 'Default location', 'abientot' ) => $this->locations->default_location() ? __( 'Present', 'abientot' ) : __( 'Missing', 'abientot' ),
),
$this->table_status()
);
}
private function table_status(): array {
global $wpdb;
$status = array();
foreach ( array( 'service_categories', 'services', 'staff', 'locations', 'customers', 'booking_statuses', 'appointments', 'availability_rules', 'availability_exceptions', 'payment_records', 'activity_log' ) as $suffix ) {
$table = $wpdb->prefix . 'abientot_' . $suffix;
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$status[ sprintf( /* translators: %s: plugin database table suffix. */ __( 'Table: %s', 'abientot' ), $suffix ) ] = $found === $table ? __( 'Present', 'abientot' ) : __( 'Missing', 'abientot' );
}
return $status;
}
private function privacy_policy_text(): string {
return __( 'Complete and publish a controller-specific privacy notice before collecting bookings. State your identity, purposes and lawful bases, retention, rights contact, recipients and transfers. The technical description below is not a complete legal policy.', 'abientot' ) . "\n\n" . __( "Abientot stores appointment booking data submitted through this site, including customer names, email addresses, phone numbers, booking notes, administrator-configured booking form field answers, selected services, appointment times, payment status for manual pay-on-site records, and booking activity needed to operate the service. Abientot uses this data to manage appointments, send booking emails through the site's WordPress mail system, and show business contact information for cancellation or rescheduling requests when enabled. Abientot does not contact external services, load remote tracking assets, or send booking data to a remote provider by default. Site administrators can export customer, service, appointment, and settings data from the WordPress admin. WordPress personal data export and erasure tools include Abientot records matched by verified current identity; historical identity changes and phone-only subjects require independent review. Identity removal clears direct contact details and private content while retaining potentially linkable booking and financial history; this is not a guarantee of irreversible anonymization. Unused customer records can be deleted. Uninstall removes Abientot data only when the delete-data-on-uninstall setting is explicitly enabled before uninstall.", 'abientot' ) . "\n\n" . __( 'Abientot uses a signed first-party cookie, abientot_availability_session, for one hour to keep availability request limits separate between browsers. It contains a random identifier and is not used for advertising or cross-site tracking.', 'abientot' );
}
private function notice(): void {
$notice = RedirectNotice::get( 'abientot_notice' );
if ( '' === $notice ) {
return;
}
$map = array(
'imported' => __( 'Settings imported.', 'abientot' ),
'import-invalid' => __( 'The settings import file is not valid.', 'abientot' ),
'import-too-large' => __( 'The settings import file is too large.', 'abientot' ),
'customer-anonymized' => __( 'Customer identity details cleared. Retained history needs review.', 'abientot' ),
'customer-deleted' => __( 'Unused customer deleted.', 'abientot' ),
'customer-invalid' => __( 'Customer could not be found.', 'abientot' ),
'customer-delete-blocked' => __( 'Customers with appointment history are preserved; anonymize this customer instead.', 'abientot' ),
'repair-failed' => __( 'Abientot database update failed. Check database permissions and reload to retry before accepting bookings.', 'abientot' ),
'repair-complete' => __( 'Repair checks completed.', 'abientot' ),
);
if ( isset( $map[ $notice ] ) ) {
echo '' . esc_html( $map[ $notice ] ) . '
';
}
}
private function send_csv_headers( string $filename ): void {
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=' . $filename );
}
/** Capability check for handlers that verified their nonce first. */
private function authorize( string $capability ): void {
if ( ! current_user_can( $capability ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'abientot' ), '', array( 'response' => 403 ) );
}
}
private function redirect_settings( string $tab, string $notice ): void {
wp_safe_redirect(
RedirectNotice::url(
add_query_arg(
array(
'page' => 'abientot-settings',
'tab' => $tab,
),
admin_url( 'admin.php' )
),
array( 'abientot_notice' => $notice )
)
);
exit;
}
private function format_price( int $cents ): string {
return number_format( $cents / 100, 2, '.', '' );
}
private function csv_cell( string $value ): string {
$value = str_replace( array( "\r", "\n" ), ' ', $value );
return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value;
}
}