import = $import; $this->service = $service; $this->customers = $customers; $this->contacts = $contacts; $this->notes = $notes; $this->activity = $activity; $this->settings = $settings; } /** * Registers the routes. * * @since 0.1.0 * * @return void */ public function register_routes() { $permission = Permissions::require_capability( Capabilities::MANAGE_CUSTOMERS ); $base = '/' . $this->rest_base; $id = array( 'id' => array( 'type' => 'integer', 'required' => true, ), ); register_rest_route( $this->namespace, $base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => $permission, 'args' => array_merge( $this->list_params( array_keys( CustomerRepository::ORDERBY ), 'id' ), array( 'status' => array( 'type' => 'string', 'default' => '', 'enum' => array_merge( array( '' ), CustomerStatus::all() ), 'validate_callback' => 'rest_validate_request_arg', ), 'tag' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array( $this, 'sanitize_tag' ), 'validate_callback' => 'rest_validate_request_arg', ), ) ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => $permission, ), ) ); $link_permission = static function () { if ( current_user_can( Capabilities::MANAGE_CUSTOMERS ) && current_user_can( 'list_users' ) ) { return true; } return Permissions::forbidden(); }; register_rest_route( $this->namespace, $base . '/wp-users', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'search_wp_users' ), 'permission_callback' => $link_permission, 'args' => array( 'search' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)/link-user', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'link_user' ), 'permission_callback' => $link_permission, 'args' => array_merge( $id, array( 'user_id' => array( 'type' => 'integer', 'required' => true, ), ) ), ) ); register_rest_route( $this->namespace, $base . '/import', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'import' ), 'permission_callback' => $permission, ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => $permission, 'args' => $id, ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => $permission, 'args' => $id, ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => $permission, 'args' => $id, ), ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)/anonymise', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'anonymise_item' ), 'permission_callback' => $permission, 'args' => $id, ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)/notes', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'add_note' ), 'permission_callback' => $permission, 'args' => $id, ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)/notes/(?P\d+)', array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_note' ), 'permission_callback' => $permission, ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)/contacts', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'add_contact' ), 'permission_callback' => $permission, 'args' => $id, ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)/contacts/(?P\d+)', array( array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_contact' ), 'permission_callback' => $permission, ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_contact' ), 'permission_callback' => $permission, ), ) ); register_rest_route( $this->namespace, $base . '/bulk', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'bulk' ), 'permission_callback' => $permission, 'args' => array( 'ids' => array( 'type' => 'array', 'required' => true, 'items' => array( 'type' => 'integer' ), 'maxItems' => CustomerService::BULK_LIMIT, ), 'action' => array( 'type' => 'string', 'required' => true, 'enum' => array( 'set_status', 'add_tag', 'remove_tag' ), ), 'value' => array( 'type' => 'string', 'required' => true, ), ), ) ); register_rest_route( $this->namespace, $base . '/tags', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_tags' ), 'permission_callback' => $permission, ) ); register_rest_route( $this->namespace, $base . '/filters', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_filters' ), 'permission_callback' => $permission, ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save_filters' ), 'permission_callback' => $permission, ), ) ); } /** * Lists customers. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_items( $request ) { $result = $this->customers->query( array( 'search' => $request['search'], 'status' => $request['status'], 'tag' => $request['tag'], 'orderby' => $request['orderby'], 'order' => $request['order'], 'page' => $request['page'], 'per_page' => $request['per_page'], ) ); $response = $this->collection_response( array_map( array( $this, 'present' ), $result['items'] ), $result['total'], $request ); $response->header( 'X-HostKhata-Status-Counts', wp_json_encode( $this->customers->count_by_status() ) ); return $response; } /** * Returns one customer with contacts, notes, activity and record counts. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_item( $request ) { $customer = $this->customers->find( (int) $request['id'] ); if ( null === $customer ) { return $this->error( 'hostkhata_not_found', __( 'Customer not found.', 'hostkhata' ), 404 ); } return rest_ensure_response( $this->present_detail( $customer ) ); } /** * Imports customers from a parsed CSV (`rows`), or only checks them (`dry_run`). * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function import( $request ) { $body = $this->request_body( $request ); $result = $this->import->import( isset( $body['rows'] ) && is_array( $body['rows'] ) ? $body['rows'] : array(), ! empty( $body['dry_run'] ), ! empty( $body['send_welcome'] ) ); return is_wp_error( $result ) ? $result : rest_ensure_response( $result ); } /** * Creates a customer. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_item( $request ) { $body = $this->body( $request ); $user_id = isset( $body['user_id'] ) ? Sanitizer::absint( $body['user_id'] ) : 0; if ( $user_id > 0 ) { // Using an existing user exposes that user, so it needs the same capability as the picker. if ( ! current_user_can( 'list_users' ) ) { return Permissions::forbidden(); } $customer = $this->service->create_from_user( $user_id, $body ); } else { $customer = $this->service->create( $body ); } if ( is_wp_error( $customer ) ) { return $customer; } $response = rest_ensure_response( $this->present_detail( $customer ) ); $response->set_status( 201 ); return $response; } /** * Updates a customer. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_item( $request ) { $customer = $this->service->update( (int) $request['id'], $this->body( $request ) ); return is_wp_error( $customer ) ? $customer : rest_ensure_response( $this->present_detail( $customer ) ); } /** * Deletes a customer that has no billing records. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function delete_item( $request ) { $result = $this->service->delete( (int) $request['id'] ); return is_wp_error( $result ) ? $result : rest_ensure_response( array( 'deleted' => true ) ); } /** * Searches WordPress users that are not customers yet. * * @since 0.1.1 * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function search_wp_users( $request ) { $term = trim( (string) $request['search'] ); $args = array( 'number' => 20, 'orderby' => 'display_name', 'order' => 'ASC', 'fields' => array( 'ID', 'user_login', 'user_email', 'display_name' ), ); if ( '' !== $term ) { $args['search'] = '*' . $term . '*'; $args['search_columns'] = array( 'user_login', 'user_email', 'user_nicename', 'display_name' ); } $users = ( new WP_User_Query( $args ) )->get_results(); $linked = $this->customers->linked_user_ids( wp_list_pluck( $users, 'ID' ) ); $items = array(); foreach ( $users as $user ) { if ( in_array( (int) $user->ID, $linked, true ) ) { continue; } $items[] = $this->present_user( (int) $user->ID ); } return rest_ensure_response( array( 'items' => array_values( array_filter( $items ) ) ) ); } /** * Links a customer to a different WordPress user. * * @since 0.1.1 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function link_user( $request ) { $customer = $this->service->link_user( (int) $request['id'], Sanitizer::absint( $request['user_id'] ) ); return is_wp_error( $customer ) ? $customer : rest_ensure_response( $this->present_detail( $customer ) ); } /** * Anonymises a customer. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function anonymise_item( $request ) { $customer = $this->service->anonymise( (int) $request['id'] ); return is_wp_error( $customer ) ? $customer : rest_ensure_response( $this->present_detail( $customer ) ); } /** * Adds a note. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function add_note( $request ) { $body = $this->body( $request ); $note = $this->service->add_note( (int) $request['id'], (string) ( $body['note'] ?? '' ), (string) ( $body['visibility'] ?? 'internal' ) ); if ( is_wp_error( $note ) ) { return $note; } $response = rest_ensure_response( $note ); $response->set_status( 201 ); return $response; } /** * Deletes a note. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function delete_note( $request ) { $result = $this->service->delete_note( (int) $request['id'], (int) $request['note_id'] ); return is_wp_error( $result ) ? $result : rest_ensure_response( array( 'deleted' => true ) ); } /** * Adds a contact. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function add_contact( $request ) { $contact = $this->service->add_contact( (int) $request['id'], $this->body( $request ) ); if ( is_wp_error( $contact ) ) { return $contact; } $response = rest_ensure_response( $contact ); $response->set_status( 201 ); return $response; } /** * Updates a contact. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_contact( $request ) { $contact = $this->service->update_contact( (int) $request['id'], (int) $request['contact_id'], $this->body( $request ) ); return is_wp_error( $contact ) ? $contact : rest_ensure_response( $contact ); } /** * Deletes a contact. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function delete_contact( $request ) { $result = $this->service->delete_contact( (int) $request['id'], (int) $request['contact_id'] ); return is_wp_error( $result ) ? $result : rest_ensure_response( array( 'deleted' => true ) ); } /** * Applies a bulk action. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function bulk( $request ) { $result = $this->service->bulk( (array) $request['ids'], (string) $request['action'], (string) $request['value'] ); return is_wp_error( $result ) ? $result : rest_ensure_response( $result ); } /** * Lists tags in use. * * @since 0.1.0 * * @return WP_REST_Response */ public function get_tags() { return rest_ensure_response( $this->customers->all_tags() ); } /** * Saved filters of the current user. * * @since 0.1.0 * * @return WP_REST_Response */ public function get_filters() { return rest_ensure_response( $this->read_filters() ); } /** * Saves the filters of the current user. The whole list is replaced. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function save_filters( $request ) { $body = $this->body( $request ); $list = isset( $body['filters'] ) && is_array( $body['filters'] ) ? $body['filters'] : null; if ( null === $list ) { return $this->error( 'hostkhata_bad_filters', __( 'Filters must be a list.', 'hostkhata' ) ); } $clean = array(); foreach ( array_slice( $list, 0, self::MAX_FILTERS ) as $filter ) { $name = isset( $filter['name'] ) ? Sanitizer::text( $filter['name'] ) : ''; if ( '' === $name || ! is_array( $filter['params'] ?? null ) ) { continue; } $params = $filter['params']; $clean[] = array( 'id' => isset( $filter['id'] ) ? sanitize_key( (string) $filter['id'] ) : wp_generate_uuid4(), 'name' => mb_substr( $name, 0, 60 ), 'params' => array( 'search' => mb_substr( Sanitizer::text( $params['search'] ?? '' ), 0, 100 ), 'status' => Sanitizer::enum( $params['status'] ?? '', CustomerStatus::all(), '' ), 'tag' => $this->sanitize_tag( $params['tag'] ?? '' ), ), ); } update_user_meta( get_current_user_id(), self::FILTERS_META, $clean ); return rest_ensure_response( $clean ); } /** * Sanitises a single tag used as a filter. * * @since 0.1.0 * * @param mixed $value Raw tag. * @return string */ public function sanitize_tag( $value ): string { $tags = Tags::parse( array( $value ) ); return $tags ? $tags[0] : ''; } /** * Request body as an array, whether it was sent as JSON or as form data. * * @param WP_REST_Request $request Request. * @return array */ private function body( WP_REST_Request $request ): array { $json = $request->get_json_params(); if ( is_array( $json ) ) { return $json; } return (array) $request->get_body_params(); } /** * Saved filters of the current user. * * @return array> */ private function read_filters(): array { $filters = get_user_meta( get_current_user_id(), self::FILTERS_META, true ); return is_array( $filters ) ? array_values( $filters ) : array(); } /** * Shapes a customer for the list view. * * @param array $customer Customer from the repository. * @return array */ private function present( array $customer ): array { $name = trim( $customer['first_name'] . ' ' . $customer['last_name'] ); return array( 'id' => $customer['id'], 'user_id' => $customer['user_id'], 'first_name' => $customer['first_name'], 'last_name' => $customer['last_name'], 'name' => '' !== $name ? $name : (string) $customer['display_name'], 'email' => $customer['user_email'], 'username' => $customer['user_login'], 'company' => $customer['company'], 'phone' => $customer['phone'], 'address_1' => $customer['address_1'], 'address_2' => $customer['address_2'], 'city' => $customer['city'], 'state' => $customer['state'], 'postcode' => $customer['postcode'], 'country' => $customer['country'], 'status' => $customer['status'], 'tags' => $customer['tags'], 'reminders_muted' => $customer['reminders_muted'], 'credit_balance' => $customer['credit_balance'], 'credit_formatted' => $this->settings->format_money( $customer['credit_balance'] ), 'created_at' => $this->iso( (string) $customer['created_at_gmt'] ), 'updated_at' => $this->iso( (string) $customer['updated_at_gmt'] ), ); } /** * Shapes a customer for the detail view. * * @param array $customer Customer from the repository. * @return array */ private function present_detail( array $customer ): array { $id = $customer['id']; $notes = array_map( function ( array $note ) { $note['created_at'] = $this->iso( (string) $note['created_at_gmt'] ); return $note; }, $this->notes->for_customer( $id ) ); $activity = array_map( function ( array $entry ) { $entry['created_at'] = $this->iso( (string) $entry['created_at_gmt'] ); return $entry; }, $this->activity->for_customer( $id ) ); return array_merge( $this->present( $customer ), array( 'contacts' => $this->contacts->for_customer( $id ), 'notes' => $notes, 'activity' => $activity, 'counts' => $this->customers->related_counts( $id ), 'wp_user' => $this->present_user( $customer['user_id'] ), ) ); } /** * Shapes a WordPress user for the picker and the customer detail. * * @param int $user_id User id. * @return array|null */ private function present_user( int $user_id ): ?array { $user = get_userdata( $user_id ); if ( ! $user instanceof WP_User ) { return null; } return array( 'id' => $user->ID, 'login' => $user->user_login, 'email' => $user->user_email, 'display_name' => $user->display_name, 'roles' => array_values( $user->roles ), 'is_staff' => array( Capabilities::ROLE_CUSTOMER ) !== array_values( $user->roles ) || user_can( $user, 'manage_options' ), ); } /** * Converts a UTC MySQL datetime to ISO 8601. * * @param string $gmt UTC datetime. * @return string */ private function iso( string $gmt ): string { return '' === $gmt ? '' : str_replace( ' ', 'T', $gmt ) . 'Z'; } }