route( '/public/catalog', 'GET', array( $this, 'catalog' ), $public ); $this->route( '/public/dates', 'GET', array( $this, 'dates' ), $public ); $this->route( '/public/slots', 'GET', array( $this, 'slots' ), $public ); $this->route( '/public/book', 'POST', array( $this, 'book' ), $public ); /* ---------- Admin ---------- */ $this->route( '/admin/bootstrap', 'GET', array( $this, 'admin_bootstrap' ), $admin ); $this->route( '/admin/dashboard', 'GET', array( $this, 'dashboard' ), $admin ); $this->route( '/admin/appointments', 'GET', array( $this, 'appointments' ), $admin ); $this->route( '/admin/appointments', 'POST', array( $this, 'appointment_create' ), $admin ); $this->route( '/admin/appointments/(?P\d+)', 'PUT', array( $this, 'appointment_update' ), $admin ); $this->route( '/admin/appointments/(?P\d+)', 'DELETE', array( $this, 'appointment_delete' ), $admin ); $this->route( '/admin/appointments/(?P\d+)/status', 'POST', array( $this, 'appointment_status' ), $admin ); $this->route( '/admin/appointments/(?P\d+)/payment', 'POST', array( $this, 'appointment_payment' ), $admin ); $this->route( '/admin/slots', 'GET', array( $this, 'slots' ), $admin ); $this->crud( 'services', array( $this, 'save_service' ) ); $this->crud( 'categories', array( $this, 'save_category' ) ); $this->crud( 'employees', array( $this, 'save_employee' ) ); $this->route( '/admin/customers', 'GET', array( $this, 'customers' ), $admin ); $this->route( '/admin/customers', 'POST', array( $this, 'save_customer' ), $admin ); $this->route( '/admin/customers/(?P\d+)', 'PUT', array( $this, 'save_customer' ), $admin ); $this->route( '/admin/customers/(?P\d+)', 'DELETE', array( $this, 'delete_customer' ), $admin ); $this->route( '/admin/settings', 'GET', array( $this, 'settings_get' ), $owner ); $this->route( '/admin/settings', 'POST', array( $this, 'settings_save' ), $owner ); $this->route( '/admin/holidays', 'POST', array( $this, 'holidays_save' ), $admin ); $this->route( '/admin/test-email', 'POST', array( $this, 'test_email' ), $owner ); } private function route( $path, $method, $cb, $perm ) { register_rest_route( self::NS, $path, array( 'methods' => $method, 'callback' => $cb, 'permission_callback' => $perm, ) ); } private function crud( $name, $save_cb ) { $admin = array( $this, 'can_manage' ); $this->route( "/admin/{$name}", 'GET', function () use ( $name ) { return rest_ensure_response( $this->list_of( $name ) ); }, $admin ); $this->route( "/admin/{$name}", 'POST', $save_cb, $admin ); $this->route( "/admin/{$name}/(?P\d+)", 'PUT', $save_cb, $admin ); $this->route( "/admin/{$name}/(?P\d+)", 'DELETE', function ( $r ) use ( $name ) { return $this->delete_entity( $name, (int) $r['id'] ); }, $admin ); } public function can_manage( $r = null ) { if ( current_user_can( 'bookzenix_manage' ) || current_user_can( 'manage_options' ) ) { return true; } return false; } /** Settings and integrations: a real logged-in manager, not an API key. */ public function is_owner() { return is_user_logged_in() && ( current_user_can( 'bookzenix_manage' ) || current_user_can( 'manage_options' ) ); } private function list_of( $name ) { switch ( $name ) { case 'services': return Repository::services(); case 'categories': return Repository::categories(); case 'employees': return Repository::employees(); } return array(); } private function delete_entity( $name, $id ) { global $wpdb; $a = Plugin::table( 'appointments' ); if ( in_array( $name, array( 'services', 'employees' ), true ) ) { $col = 'services' === $name ? 'service_id' : 'employee_id'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $future = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$a} WHERE {$col} = %d AND start_utc > %s AND status IN ('pending','approved')", $id, gmdate( 'Y-m-d H:i:s' ) ) ); if ( $future ) { /* translators: %d number of appointments */ return new \WP_Error( 'bookzenix_in_use', sprintf( __( 'Cannot delete: %d upcoming appointments use it. Set it inactive instead.', 'bookzenix' ), $future ), array( 'status' => 409 ) ); } $wpdb->delete( Plugin::table( 'employee_services' ), array( $col => $id ) ); if ( 'employees' === $name ) { $wpdb->delete( Plugin::table( 'working_hours' ), array( 'employee_id' => $id ) ); $wpdb->delete( Plugin::table( 'days_off' ), array( 'employee_id' => $id ) ); } } Repository::delete( $name, $id ); return rest_ensure_response( array( 'deleted' => true ) ); } /* ================================================================= * PUBLIC * ================================================================= */ public function catalog() { $services = array_values( array_filter( Repository::services( true ), function ( $s ) { return ! empty( $s['employee_ids'] ); } ) ); $services = array_map( function ( $s ) { unset( $s['created_at'], $s['position'] ); $s['name'] = I18n::t( 'service_' . $s['id'] . '_name', $s['name'] ); $s['description'] = wp_kses_post( I18n::t( 'service_' . $s['id'] . '_description', $s['description'] ) ); $s['gallery'] = array_values( array_filter( (array) json_decode( (string) $s['gallery'], true ) ) ); $s = array_intersect_key( $s, array_flip( array( 'id', 'category_id', 'name', 'description', 'duration', 'price', 'color', 'image_url', 'gallery', 'location_type', 'location_text', 'employee_ids' ) ) ); return $s; }, $services ); $employees = array_map( function ( $e ) { return array( 'id' => (int) $e['id'], 'name' => trim( $e['first_name'] . ' ' . $e['last_name'] ), 'bio' => $e['bio'], 'avatar_url' => $e['avatar_url'], 'service_ids' => $e['service_ids'], ); }, Repository::employees( true ) ); $cats = array_map( function ( $c ) { $c['name'] = I18n::t( 'category_' . $c['id'] . '_name', $c['name'] ); return $c; }, Repository::categories() ); $settings = Settings::public_settings(); $out = array( 'categories' => $cats, 'services' => $services, 'employees' => $employees, 'settings' => $settings, ); return rest_ensure_response( $out ); } public function dates( \WP_REST_Request $r ) { $month = preg_match( '/^\d{4}-\d{2}$/', (string) $r['month'] ) ? $r['month'] : wp_date( 'Y-m' ); list( $y, $m ) = array_map( 'intval', explode( '-', $month ) ); $key = 'bookzenix_dates_' . md5( wp_json_encode( array( $r['service_id'], $r['employee_id'], $month, get_option( 'bookzenix_avail_ver', 0 ) ) ) ); $cached = get_transient( $key ); if ( false === $cached ) { $cached = Availability::available_dates( (int) $r['service_id'], (int) $r['employee_id'], $y, $m ); set_transient( $key, $cached, MINUTE_IN_SECONDS ); } return rest_ensure_response( array( 'dates' => $cached ) ); } public function slots( \WP_REST_Request $r ) { $date = Repository::clean_date( $r['date'] ); if ( ! $date ) { return new \WP_Error( 'bookzenix_date', 'Invalid date', array( 'status' => 400 ) ); } return rest_ensure_response( array( 'slots' => Availability::slots( (int) $r['service_id'], (int) $r['employee_id'], $date ) ) ); } private function rate_limited( $bucket = 'book', $max = 10 ) { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0'; $rl = 'bookzenix_rl_' . $bucket . '_' . md5( $ip ); $count = (int) get_transient( $rl ); if ( $this->can_manage() ) { return false; // staff testing their own form are never throttled } if ( $count >= (int) apply_filters( 'bookzenix_rate_limit', $max, $bucket ) ) { return true; } set_transient( $rl, $count + 1, 10 * MINUTE_IN_SECONDS ); return false; } public function book( \WP_REST_Request $r ) { if ( ! empty( $r['website'] ) ) { // honeypot return new \WP_Error( 'bookzenix_spam', 'Rejected', array( 'status' => 400 ) ); } if ( $this->rate_limited() ) { return new \WP_Error( 'bookzenix_rate', __( 'Too many booking attempts. Please wait a few minutes.', 'bookzenix' ), array( 'status' => 429 ) ); } $params = $r->get_json_params() ?: $r->get_params(); $res = Booking::checkout( $params, false ); if ( is_wp_error( $res ) ) { return $res; } $list = array_map( function ( $a ) { return Repository::appointment( $a['id'] ) ?: $a; }, $res['appointments'] ); $out = array( 'appointment' => $this->public_appointment( $list[0] ), 'appointments' => array_map( array( $this, 'public_appointment' ), $list ), ); return rest_ensure_response( $out ); } /** Admin sells/gives a package to a customer (paid on site). */ /* ---------- events ---------- */ /** Customer edits their own name / phone from the panel. */ /** "Email me my link" – magic-link login for the customer panel. */ private function public_appointment( $a ) { $keys = array( 'id', 'service_id', 'employee_id', 'service_name', 'service_color', 'employee_name', 'date_local', 'time_local', 'end_local', 'start_iso', 'end_iso', 'status', 'price', 'duration', 'location_type', 'location_text', 'length' ); $out = array_intersect_key( $a, array_flip( $keys ) ); return $out; } /* ================================================================= * ADMIN * ================================================================= */ public function admin_bootstrap() { return rest_ensure_response( array( 'services' => Repository::services(), 'categories' => Repository::categories(), 'employees' => Repository::employees(), 'settings' => $this->safe_settings(), 'holidays' => Repository::company_days_off(), 'placeholders' => Notifications::placeholders(), 'timezone' => wp_timezone_string(), 'site' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url() ), 'pretty_permalinks' => (bool) get_option( 'permalink_structure' ), 'is_admin' => current_user_can( 'manage_options' ), ) ); } public function dashboard( \WP_REST_Request $r ) { $tz = wp_timezone(); $days = in_array( (int) $r['days'], array( 7, 30, 90, 365 ), true ) ? (int) $r['days'] : 30; $to = ( new \DateTimeImmutable( 'tomorrow', $tz ) )->getTimestamp(); $from = $to - $days * DAY_IN_SECONDS; $stats = Repository::stats( gmdate( 'Y-m-d H:i:s', $from ), gmdate( 'Y-m-d H:i:s', $to ) ); $today_start = ( new \DateTimeImmutable( 'today', $tz ) )->getTimestamp(); $stats['today'] = Repository::appointments( array( 'from' => gmdate( 'Y-m-d H:i:s', $today_start ), 'to' => gmdate( 'Y-m-d H:i:s', $today_start + DAY_IN_SECONDS ), ) ); $stats['upcoming'] = Repository::appointments( array( 'from' => gmdate( 'Y-m-d H:i:s' ), 'limit' => 8, ) ); $stats['pending'] = Repository::appointments( array( 'status' => 'pending', 'from' => gmdate( 'Y-m-d H:i:s' ), 'limit' => 20 ) ); return rest_ensure_response( $stats ); } public function appointments( \WP_REST_Request $r ) { $f = array( 'from' => $r['from'] ? gmdate( 'Y-m-d H:i:s', strtotime( $r['from'] ) ) : null, 'to' => $r['to'] ? gmdate( 'Y-m-d H:i:s', strtotime( $r['to'] ) ) : null, 'employee_id' => $r['employee_id'], 'service_id' => $r['service_id'], 'customer_id' => $r['customer_id'], 'status' => $r['status'] ? sanitize_key( $r['status'] ) : null, 'search' => $r['search'] ? sanitize_text_field( $r['search'] ) : null, 'order' => 'desc' === $r['order'] ? 'desc' : 'asc', 'limit' => min( 1000, (int) ( $r['limit'] ?: 500 ) ), 'overlap' => ! empty( $r['overlap'] ), ); return rest_ensure_response( Repository::appointments( $f ) ); } public function appointment_create( \WP_REST_Request $r ) { $res = Booking::create( $r->get_json_params() ?: $r->get_params(), true ); return is_wp_error( $res ) ? $res : rest_ensure_response( $res ); } public function appointment_update( \WP_REST_Request $r ) { $id = (int) $r['id']; $appt = Repository::appointment( $id ); if ( ! $appt ) { return new \WP_Error( 'bookzenix_404', 'Not found', array( 'status' => 404 ) ); } $p = $r->get_json_params(); if ( ! empty( $p['date'] ) && ! empty( $p['time'] ) && ( $p['date'] !== $appt['date_local'] || $p['time'] !== $appt['time_local'] || ( ! empty( $p['employee_id'] ) && (int) $p['employee_id'] !== (int) $appt['employee_id'] ) ) ) { if ( ! empty( $p['employee_id'] ) ) { Repository::update_appointment( $id, array( 'employee_id' => (int) $p['employee_id'] ) ); $appt = Repository::appointment( $id ); } $res = Booking::reschedule( $appt, Repository::clean_date( $p['date'] ), $p['time'], true ); if ( is_wp_error( $res ) ) { return $res; } } $upd = array(); foreach ( array( 'price' ) as $k ) { if ( isset( $p[ $k ] ) ) { $upd[ $k ] = (float) $p[ $k ]; } } if ( isset( $p['internal_note'] ) ) { $upd['internal_note'] = sanitize_textarea_field( $p['internal_note'] ); } if ( $upd ) { Repository::update_appointment( $id, $upd ); if ( $appt['order_id'] ) { Orders::recalc( $appt['order_id'] ); Orders::apply_totals( $appt['order_id'] ); } } if ( ! empty( $p['status'] ) && $p['status'] !== $appt['status'] ) { $res = Booking::set_status( $id, sanitize_key( $p['status'] ) ); if ( is_wp_error( $res ) ) { return $res; } } return rest_ensure_response( Repository::appointment( $id ) ); } public function appointment_delete( \WP_REST_Request $r ) { global $wpdb; $id = (int) $r['id']; $appt = Repository::appointment( $id ); $wpdb->delete( Plugin::table( 'payments' ), array( 'appointment_id' => $id ) ); Repository::delete( 'appointments', $id ); if ( $appt && $appt['order_id'] ) { if ( ! Orders::appointments( $appt['order_id'] ) ) { $wpdb->delete( Plugin::table( 'payments' ), array( 'order_id' => $appt['order_id'] ) ); Repository::delete( 'orders', $appt['order_id'] ); } else { Orders::recalc( $appt['order_id'] ); Orders::apply_totals( $appt['order_id'] ); } } return rest_ensure_response( array( 'deleted' => true ) ); } public function appointment_status( \WP_REST_Request $r ) { $res = Booking::set_status( (int) $r['id'], sanitize_key( (string) $r['status'] ) ); return is_wp_error( $res ) ? $res : rest_ensure_response( $res ); } public function appointment_payment( \WP_REST_Request $r ) { $amount = round( (float) $r['amount'], 2 ); if ( $amount <= 0 ) { return new \WP_Error( 'bookzenix_amount', 'Invalid amount', array( 'status' => 400 ) ); } Booking::record_payment( (int) $r['id'], 'on_site', $amount, 'manual-' . time() ); $a = Repository::appointment( (int) $r['id'] ); $a['payments'] = Repository::payments_for( (int) $r['id'] ); return rest_ensure_response( $a ); } public function save_service( \WP_REST_Request $r ) { $p = $r->get_json_params(); $d = array( 'category_id' => ! empty( $p['category_id'] ) ? (int) $p['category_id'] : null, 'name' => sanitize_text_field( $p['name'] ?? '' ), 'description' => wp_kses_post( $p['description'] ?? '' ), 'duration' => max( 5, (int) ( $p['duration'] ?? 60 ) ), 'price' => max( 0, (float) ( $p['price'] ?? 0 ) ), 'buffer_before' => max( 0, (int) ( $p['buffer_before'] ?? 0 ) ), 'buffer_after' => max( 0, (int) ( $p['buffer_after'] ?? 0 ) ), 'color' => sanitize_hex_color( $p['color'] ?? '' ) ?: '#6366f1', 'image_url' => esc_url_raw( $p['image_url'] ?? '' ), 'gallery' => wp_json_encode( array_slice( array_values( array_filter( array_map( 'esc_url_raw', array_map( 'strval', (array) ( $p['gallery'] ?? array() ) ) ) ) ), 0, 20 ) ), 'location_text' => sanitize_text_field( $p['location_text'] ?? '' ), 'status' => 'inactive' === ( $p['status'] ?? '' ) ? 'inactive' : 'active', 'position' => (int) ( $p['position'] ?? 0 ), 'employee_ids' => (array) ( $p['employee_ids'] ?? array() ), ); if ( '' === $d['name'] ) { return new \WP_Error( 'bookzenix_name', __( 'Name is required.', 'bookzenix' ), array( 'status' => 400 ) ); } $id = Repository::save_service( $d, (int) $r['id'] ); $s = Repository::service( $id ); $s['employee_ids'] = Repository::employee_ids_for_service( $id ); return rest_ensure_response( $s ); } public function save_category( \WP_REST_Request $r ) { $p = $r->get_json_params(); $id = Repository::save( 'categories', array( 'name' => sanitize_text_field( $p['name'] ?? '' ), 'color' => sanitize_hex_color( $p['color'] ?? '' ) ?: '#6366f1', 'position' => (int) ( $p['position'] ?? 0 ), ), array( 'name', 'color', 'position' ), (int) $r['id'] ); return rest_ensure_response( Repository::find( 'categories', $id ) ); } public function save_employee( \WP_REST_Request $r ) { $p = $r->get_json_params(); $id = (int) $r['id']; $email = sanitize_email( $p['email'] ?? '' ); if ( empty( $p['first_name'] ) || ! is_email( $email ) ) { return new \WP_Error( 'bookzenix_emp', __( 'First name and a valid email are required.', 'bookzenix' ), array( 'status' => 400 ) ); } $d = array( 'first_name' => sanitize_text_field( $p['first_name'] ), 'last_name' => sanitize_text_field( $p['last_name'] ?? '' ), 'email' => $email, 'phone' => sanitize_text_field( $p['phone'] ?? '' ), 'bio' => sanitize_textarea_field( $p['bio'] ?? '' ), 'avatar_url' => esc_url_raw( $p['avatar_url'] ?? '' ), 'status' => 'inactive' === ( $p['status'] ?? '' ) ? 'inactive' : 'active', 'service_ids' => (array) ( $p['service_ids'] ?? array() ), 'working_hours' => (array) ( $p['working_hours'] ?? array() ), 'days_off' => (array) ( $p['days_off'] ?? array() ), ); $id = Repository::save_employee( $d, $id ); return rest_ensure_response( Repository::employee( $id ) ); } public function customers( \WP_REST_Request $r ) { return rest_ensure_response( Repository::customers( sanitize_text_field( (string) $r['search'] ), max( 1, (int) $r['page'] ), 20 ) ); } public function save_customer( \WP_REST_Request $r ) { $p = $r->get_json_params(); $email = sanitize_email( $p['email'] ?? '' ); if ( empty( $p['first_name'] ) || ! is_email( $email ) ) { return new \WP_Error( 'bookzenix_cust', __( 'First name and a valid email are required.', 'bookzenix' ), array( 'status' => 400 ) ); } $id = (int) $r['id']; if ( $id ) { Repository::save( 'customers', array( 'first_name' => sanitize_text_field( $p['first_name'] ), 'last_name' => sanitize_text_field( $p['last_name'] ?? '' ), 'email' => $email, 'phone' => sanitize_text_field( $p['phone'] ?? '' ), 'note' => sanitize_textarea_field( $p['note'] ?? '' ), ), array( 'first_name', 'last_name', 'email', 'phone', 'note' ), $id ); $c = Repository::find( 'customers', $id ); } else { $c = Repository::upsert_customer( $p ); } unset( $c['access_token'] ); return rest_ensure_response( $c ); } public function delete_customer( \WP_REST_Request $r ) { global $wpdb; $id = (int) $r['id']; $a = Plugin::table( 'appointments' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$a} WHERE customer_id = %d", $id ) ); foreach ( $ids as $aid ) { $wpdb->delete( Plugin::table( 'payments' ), array( 'appointment_id' => $aid ) ); } $wpdb->delete( $a, array( 'customer_id' => $id ) ); foreach ( $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM ' . Plugin::table( 'orders' ) . ' WHERE customer_id = %d', $id ) ) as $oid ) { // phpcs:ignore $wpdb->delete( Plugin::table( 'payments' ), array( 'order_id' => $oid ) ); } foreach ( array( 'orders', 'package_purchases', 'event_bookings' ) as $t ) { $wpdb->delete( Plugin::table( $t ), array( 'customer_id' => $id ) ); } Repository::delete( 'customers', $id ); return rest_ensure_response( array( 'deleted' => true ) ); } /** Never send secret keys back to the browser in full. */ private function safe_settings() { $s = Settings::all(); $s['design'] = Design::get(); return $s; } public function settings_get() { return rest_ensure_response( $this->safe_settings() ); } public function settings_save( \WP_REST_Request $r ) { $p = (array) $r->get_json_params(); $clean = array(); $text = array( 'company_name', 'company_phone', 'currency_symbol', 'font_family' ); foreach ( $text as $k ) { if ( isset( $p[ $k ] ) ) { $clean[ $k ] = sanitize_text_field( $p[ $k ] ); } } if ( isset( $p['company_email'] ) && is_email( $p['company_email'] ) ) { $clean['company_email'] = sanitize_email( $p['company_email'] ); } if ( isset( $p['currency'] ) ) { $clean['currency'] = strtoupper( substr( preg_replace( '/[^A-Za-z]/', '', $p['currency'] ), 0, 3 ) ); } $ints = array( 'time_slot_step', 'min_notice', 'max_advance_days' ); $bools = array( 'require_phone' ); foreach ( $ints as $k ) { if ( isset( $p[ $k ] ) ) { $clean[ $k ] = max( 0, (int) $p[ $k ] ); } } foreach ( $bools as $k ) { if ( isset( $p[ $k ] ) ) { $clean[ $k ] = (bool) $p[ $k ]; } } if ( isset( $p['default_status'] ) ) { $clean['default_status'] = 'pending' === $p['default_status'] ? 'pending' : 'approved'; } if ( isset( $p['design'] ) && is_array( $p['design'] ) ) { $clean['design'] = Design::sanitize( $p['design'] ); } if ( isset( $p['font_family'] ) ) { $clean['font_family'] = sanitize_text_field( $p['font_family'] ); } foreach ( array( 'primary_color', 'accent_color' ) as $k ) { if ( isset( $p[ $k ] ) && sanitize_hex_color( $p[ $k ] ) ) { $clean[ $k ] = sanitize_hex_color( $p[ $k ] ); } } if ( isset( $p['notifications'] ) && is_array( $p['notifications'] ) ) { $clean['notifications'] = array_map( 'boolval', $p['notifications'] ); } if ( isset( $p['templates'] ) && is_array( $p['templates'] ) ) { foreach ( $p['templates'] as $k => $t ) { $clean['templates'][ sanitize_key( $k ) ] = array( 'subject' => sanitize_text_field( $t['subject'] ?? '' ), 'body' => sanitize_textarea_field( $t['body'] ?? '' ), ); } } Settings::update( $clean ); return rest_ensure_response( $this->safe_settings() ); } /* ---------- integrations ---------- */ /** Re-download every iCal link (after saving). */ /** "Sync now": read connected calendars and apply changes to bookings. */ /** "Send test" buttons on the Integrations screen. */ public function holidays_save( \WP_REST_Request $r ) { Repository::save_company_days_off( (array) $r->get_json_params()['holidays'] ); return rest_ensure_response( Repository::company_days_off() ); } public function test_email() { $fake = array( 'id' => 0, 'customer_first_name' => 'Alex', 'customer_name' => 'Alex Morgan', 'customer_email' => Settings::get( 'company_email' ), 'service_name' => 'Test Service', 'employee_name' => 'Jamie Lee', 'start_utc' => gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS ), 'end_utc' => gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS + HOUR_IN_SECONDS ), 'price' => 50, 'discount' => 0, 'status' => 'approved', 'token' => '', ); $ok = Notifications::send( 'customer_approved', $fake, Settings::get( 'company_email' ) ); return rest_ensure_response( array( 'sent' => $ok ) ); } }