|null */ protected function record_for( WP_REST_Request $request ): ?array { $id = (int) ( $request['id'] ?? 0 ); // The category routes share this controller and name a CATEGORY id. Loading the article that happens // to share it made a category save 409 against an unrelated article's `modified_at`. if ( $id <= 0 || str_contains( (string) $request->get_route(), '/kb_categories/' ) ) { return null; } return $this->articles->find( $id ); } public function register_routes(): void { register_rest_route( $this->namespace, '/kb_articles', array( array( 'methods' => 'GET', 'callback' => array( $this, 'index' ), 'permission_callback' => array( $this, 'can_read' ), 'args' => $this->get_collection_params(), ), array( 'methods' => 'POST', 'callback' => array( $this, 'store' ), 'permission_callback' => array( $this, 'can_create' ), ), ) ); register_rest_route( $this->namespace, '/kb_articles/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'show' ), 'permission_callback' => array( $this, 'can_read' ), ), array( 'methods' => 'PUT, PATCH', 'callback' => array( $this, 'update' ), 'permission_callback' => array( $this, 'can_edit' ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'destroy' ), 'permission_callback' => array( $this, 'can_delete' ), ), ) ); /* * THE TRASH HALF OF THE ENTITY CONTRACT, which was missing. `EntityList` offers every entity a * Trash view with Restore and Delete permanently on it — the buttons were there, the routes were * not, so both actions 404'd. Every other entity registers these; the knowledge base is simply * the one that was written without them. */ register_rest_route( $this->namespace, '/kb_articles/(?P\d+)/restore', array( array( 'methods' => 'POST', 'callback' => array( $this, 'restore' ), 'permission_callback' => array( $this, 'can_edit' ), ), ) ); register_rest_route( $this->namespace, '/kb_articles/(?P\d+)/purge', array( array( 'methods' => 'DELETE', 'callback' => array( $this, 'purge' ), 'permission_callback' => array( $this, 'can_delete' ), ), ) ); register_rest_route( $this->namespace, '/kb_categories', array( array( 'methods' => 'GET', 'callback' => array( $this, 'categories' ), 'permission_callback' => array( $this, 'can_read' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'store_category' ), 'permission_callback' => array( $this, 'can_create' ), ), ) ); register_rest_route( $this->namespace, '/kb_categories/(?P\d+)', array( array( 'methods' => 'PUT, PATCH', 'callback' => array( $this, 'update_category' ), 'permission_callback' => array( $this, 'can_edit' ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'destroy_category' ), 'permission_callback' => array( $this, 'can_delete' ), ), ) ); // The articles attached to a case. Read and written from the case's own screen, which is the // only place anybody links one. register_rest_route( $this->namespace, '/cases/(?P\d+)/articles', array( array( 'methods' => 'GET', 'callback' => array( $this, 'case_articles' ), 'permission_callback' => array( $this, 'can_read' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'set_case_articles' ), 'permission_callback' => array( $this, 'can_edit' ), ), ) ); } /** * @param WP_REST_Request $request The request. */ public function index( WP_REST_Request $request ): WP_REST_Response|WP_Error { return $this->paged_list( $request, $this->articles, array( 't.name', 't.body_plain' ), array( $this, 'narrow_to_category' ), fn( array $row ): array => $this->service->decorate( $row ) ); } /** * `?category=` — and it INCLUDES SUB-CATEGORIES, which is the whole point of the closure table. * * Selecting "Billing" and being shown nothing because every article is filed one level down in * "Billing / Refunds" is the failure this exists to prevent. EspoCRM answers it the same way: its * `inCategory` filter joins the category's Path table on `descendorId` and matches `ascendorId`, * for exactly this reason. * * EXISTS RATHER THAN A JOIN. An article can sit in several categories, so joining the link table * would return it once per matching category — and `paged_list()` counts before it paginates, so * the total would be wrong and the last page would be empty. The same reason `AccessPolicy` uses * EXISTS. * * `category=0` means uncategorised, mirroring `folder=0` on the document library. * * @param QueryBuilder $query The list query. * @param WP_REST_Request $request The request. */ public function narrow_to_category( QueryBuilder $query, WP_REST_Request $request ): void { $category = $request->get_param( 'category' ); if ( null === $category || '' === $category ) { return; } $links = Schema::table( 'kb_article_categories' ); if ( 0 === (int) $category ) { $query->where( 'NOT EXISTS ( SELECT 1 FROM %i l WHERE l.article_id = t.id AND l.deleted_at IS NULL )', array( $links ) ); return; } $paths = Schema::table( CategorySchema::path_table( 'kb_categories' ) ); $query->where( 'EXISTS ( SELECT 1 FROM %i l INNER JOIN %i p ON p.descendant_id = l.category_id WHERE l.article_id = t.id AND l.deleted_at IS NULL AND p.ancestor_id = %d )', array( $links, $paths, (int) $category ) ); } /** * @param WP_REST_Request $request The request. */ public function show( WP_REST_Request $request ): WP_REST_Response|WP_Error { $article = $this->service->find( (int) $request['id'] ); if ( null === $article ) { return $this->fail( 'plugixa_crm_not_found', __( 'That article does not exist.', 'plugixa-crm' ), 404 ); } return $this->ok( $article ); } /** * @param WP_REST_Request $request The request. */ public function store( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = $this->service->create( (array) $request->get_json_params() ); if ( $id instanceof WP_Error ) { return $id; } return $this->ok( $this->service->find( $id ), array(), 201 ); } /** * @param WP_REST_Request $request The request. */ public function update( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = (int) $request['id']; $done = $this->service->update( $id, (array) $request->get_json_params() ); if ( $done instanceof WP_Error ) { return $done; } return $this->ok( $this->service->find( $id ) ); } /** * @param WP_REST_Request $request The request. */ public function destroy( WP_REST_Request $request ): WP_REST_Response|WP_Error { if ( ! $this->articles->delete( (int) $request['id'] ) ) { return $this->fail( 'plugixa_crm_not_found', __( 'That article does not exist.', 'plugixa-crm' ), 404 ); } return $this->ok( array( 'deleted' => true ) ); } /** * @param WP_REST_Request $request The request. */ public function restore( WP_REST_Request $request ): WP_REST_Response|WP_Error { if ( ! $this->articles->restore( (int) $request['id'] ) ) { return $this->fail( 'plugixa_crm_not_found', __( 'That article is not in the trash.', 'plugixa-crm' ), 404 ); } return $this->ok( array( 'restored' => true ) ); } /** * @param WP_REST_Request $request The request. */ public function purge( WP_REST_Request $request ): WP_REST_Response|WP_Error { if ( ! $this->articles->purge( (int) $request['id'] ) ) { return $this->fail( 'plugixa_crm_not_found', __( 'That article does not exist.', 'plugixa-crm' ), 404 ); } return $this->ok( array( 'purged' => true ) ); } /** * @param WP_REST_Request $request The request. */ public function categories( WP_REST_Request $request ): WP_REST_Response { unset( $request ); return $this->ok( $this->service->categories() ); } /** * @param WP_REST_Request $request The request. */ public function store_category( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = $this->service->create_category( (array) $request->get_json_params() ); if ( $id instanceof WP_Error ) { return $id; } return $this->ok( $this->service->categories(), array(), 201 ); } /** * @param WP_REST_Request $request The request. */ public function update_category( WP_REST_Request $request ): WP_REST_Response|WP_Error { $done = $this->service->update_category( (int) $request['id'], (array) $request->get_json_params() ); if ( $done instanceof WP_Error ) { return $done; } return $this->ok( $this->service->categories() ); } /** * @param WP_REST_Request $request The request. */ public function destroy_category( WP_REST_Request $request ): WP_REST_Response|WP_Error { if ( ! $this->service->delete_category( (int) $request['id'] ) ) { return $this->fail( 'plugixa_crm_not_found', __( 'That category does not exist.', 'plugixa-crm' ), 404 ); } return $this->ok( $this->service->categories() ); } /** * @param WP_REST_Request $request The request. */ public function case_articles( WP_REST_Request $request ): WP_REST_Response { return $this->ok( $this->service->articles_for_case( (int) $request['case_id'] ) ); } /** * @param WP_REST_Request $request The request. */ public function set_case_articles( WP_REST_Request $request ): WP_REST_Response { $case_id = (int) $request['case_id']; $body = (array) $request->get_json_params(); $this->service->set_articles_for_case( $case_id, (array) ( $body['articles'] ?? array() ) ); return $this->ok( $this->service->articles_for_case( $case_id ) ); } }