| @@ -13,11 +13,15 @@ | ||
| 13 | 13 | |
| 14 | 14 | public function register() { |
| 15 | 15 | $this->get( 'faq-terms-by-keyword-search', [$this, 'search_logic'], [ |
| 16 | 16 | 'password' => [ |
| 17 | - 'description' => __( 'The password for password-protected FAQs.' ), | |
| 17 | + 'description' => __( 'The password for password-protected FAQs.', 'betterdocs' ), | |
| 18 | 18 | 'type' => 'string', |
| 19 | 19 | ], |
| 20 | + 'taxonomy' => [ | |
| 21 | + 'description' => __( 'The FAQ taxonomy to search within.', 'betterdocs' ), | |
| 22 | + 'type' => 'string', | |
| 23 | + ], | |
| 20 | 24 | ] ); |
| 21 | 25 | $this->post( 'faq-accordion-toggle', [$this, 'toggle_enable_disable'] ); |
| 22 | 26 | } |
| 23 | 27 | |
| @@ -28,9 +32,20 @@ | ||
| 28 | 32 | $error = new WP_Error( 400, __( 'FAQ Search Parameter Cannot Be Empty', 'betterdocs' ) ); |
| 29 | 33 | return rest_ensure_response( $error ); |
| 30 | 34 | } |
| 31 | 35 | |
| 36 | + // Scope the search to the requested FAQ taxonomy (General vs. WooCommerce | |
| 37 | + // Product FAQ Groups). Whitelisted so an arbitrary taxonomy can't be queried. | |
| 38 | + $allowed_taxonomies = [ 'betterdocs_faq_category', 'betterdocs_product_faq_category' ]; | |
| 39 | + $taxonomy = $request->get_param( 'taxonomy' ); | |
| 40 | + if ( ! in_array( $taxonomy, $allowed_taxonomies, true ) ) { | |
| 41 | + $taxonomy = 'betterdocs_faq_category'; | |
| 42 | + } | |
| 43 | + | |
| 32 | 44 | $term_ids = []; |
| 45 | + // Map of category term_id => [ matched FAQ ids ] so the UI can show | |
| 46 | + // only the FAQs that matched the keyword (not the whole group). | |
| 47 | + $term_faq_map = []; | |
| 33 | 48 | |
| 34 | 49 | // Determine allowed post statuses based on user permissions |
| 35 | 50 | $post_status = ['publish']; |
| 36 | 51 | if( current_user_can( 'read_private_docs' ) ) { |
| @@ -35,8 +50,15 @@ | ||
| 35 | 50 | $post_status = ['publish']; |
| 36 | 51 | if( current_user_can( 'read_private_docs' ) ) { |
| 37 | 52 | $post_status[] = 'private'; |
| 38 | 53 | } |
| 54 | + // The FAQ Builder is an editor-facing tool, so draft FAQs must be | |
| 55 | + // searchable too. QA-006: gate drafts on `edit_others_posts` (Editor+) | |
| 56 | + // rather than `edit_posts` (Author) — the query has no author scope, so | |
| 57 | + // an Author-level user would otherwise see every author's draft FAQs. | |
| 58 | + if( current_user_can( 'edit_others_posts' ) ) { | |
| 59 | + $post_status[] = 'draft'; | |
| 60 | + } | |
| 39 | 61 | |
| 40 | 62 | $args = [ |
| 41 | 63 | 'post_type' => 'betterdocs_faq', |
| 42 | 64 | 'post_status' => $post_status, |
| @@ -62,13 +84,14 @@ | ||
| 62 | 84 | continue; // Skip this FAQ |
| 63 | 85 | } |
| 64 | 86 | } |
| 65 | 87 | |
| 66 | - $categories = get_the_terms( get_the_ID(), 'betterdocs_faq_category' ); | |
| 88 | + $categories = get_the_terms( get_the_ID(), $taxonomy ); | |
| 67 | 89 | |
| 68 | 90 | if ( $categories ) { |
| 69 | 91 | foreach ( $categories as $category ) { |
| 70 | - $term_ids[] = $category->term_id; | |
| 92 | + $term_ids[] = $category->term_id; | |
| 93 | + $term_faq_map[ $category->term_id ][] = (int) get_the_ID(); | |
| 71 | 94 | } |
| 72 | 95 | } |
| 73 | 96 | } |
| 74 | 97 | wp_reset_postdata(); |
| @@ -75,9 +98,9 @@ | ||
| 75 | 98 | } |
| 76 | 99 | |
| 77 | 100 | $terms = get_terms( |
| 78 | 101 | [ |
| 79 | - 'taxonomy' => 'betterdocs_faq_category', | |
| 102 | + 'taxonomy' => $taxonomy, | |
| 80 | 103 | 'hide_empty' => false, |
| 81 | 104 | 'search' => $keyword |
| 82 | 105 | ] |
| 83 | 106 | ); |
| @@ -95,9 +118,9 @@ | ||
| 95 | 118 | $terms_with_meta = []; |
| 96 | 119 | |
| 97 | 120 | $terms_payload = get_terms( |
| 98 | 121 | [ |
| 99 | - 'taxonomy' => 'betterdocs_faq_category', | |
| 122 | + 'taxonomy' => $taxonomy, | |
| 100 | 123 | 'hide_empty' => false, |
| 101 | 124 | 'include' => $term_ids |
| 102 | 125 | ] |
| 103 | 126 | ); |
| @@ -105,8 +128,13 @@ | ||
| 105 | 128 | foreach ( $terms_payload as $term ) { |
| 106 | 129 | $meta = get_term_meta( $term->term_id ); |
| 107 | 130 | $meta['_betterdocs_faq_order'] = empty( get_term_meta( $term->term_id, '_betterdocs_faq_order', true ) ) ? [] : [get_term_meta( $term->term_id, '_betterdocs_faq_order', true )]; |
| 108 | 131 | $term->meta = $meta; |
| 132 | + // FAQs in this group that matched the keyword. Empty when the group | |
| 133 | + // was matched only by its name (then the UI shows the whole group). | |
| 134 | + $term->matched_faqs = isset( $term_faq_map[ $term->term_id ] ) | |
| 135 | + ? array_values( array_unique( $term_faq_map[ $term->term_id ] ) ) | |
| 136 | + : []; | |
| 109 | 137 | array_push( $terms_with_meta, $term ); |
| 110 | 138 | } |
| 111 | 139 | |
| 112 | 140 | return rest_ensure_response( $terms_with_meta ); |
| @@ -113,10 +141,23 @@ | ||
| 113 | 141 | } |
| 114 | 142 | |
| 115 | 143 | public function toggle_enable_disable( $request ) { |
| 116 | 144 | $body_params = json_decode( $request->get_body() ); |
| 117 | - $faq_id = isset( $body_params->faq_id ) ? $body_params->faq_id : 0; | |
| 118 | - $toggle = $body_params->toggle; | |
| 145 | + | |
| 146 | + // QA-005: reject invalid / empty JSON instead of dereferencing null | |
| 147 | + // (a PHP 8+ fatal: "Attempt to read property on null"). | |
| 148 | + if ( ! is_object( $body_params ) ) { | |
| 149 | + return new WP_Error( | |
| 150 | + 'rest_invalid_json', | |
| 151 | + __( 'Invalid request body.', 'betterdocs' ), | |
| 152 | + array( 'status' => 400 ) | |
| 153 | + ); | |
| 154 | + } | |
| 155 | + | |
| 156 | + $faq_id = isset( $body_params->faq_id ) ? absint( $body_params->faq_id ) : 0; | |
| 157 | + // QA-005: normalize to a stored boolean ('1'/'0') — never write the raw | |
| 158 | + // request value straight to post meta. | |
| 159 | + $toggle = ( isset( $body_params->toggle ) && $body_params->toggle ) ? '1' : '0'; | |
| 119 | 160 | |
| 120 | 161 | if ( $faq_id != 0 ) { |
| 121 | 162 | // Security check: Verify user can edit this FAQ post |
| 122 | 163 | if ( ! current_user_can( 'edit_post', $faq_id ) ) { |