PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Core/FAQBuilder.php +814 -80 4.5.34.9.2 View file →
@@ -1,10 +1,19 @@
1 1 <?php
2 +namespace WPDeveloper\BetterDocs\Core;
2 3
3 -namespace WPDeveloper\BetterDocs\Core;
4 +if ( ! defined( 'ABSPATH' ) ) {
5 + exit;
6 +}
4 7
8 +// FAQ-by-category lookups require tax_query / meta_key filters by design.
9 +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
10 +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
11 +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query
12 +
5 13 use WP_Error;
6 14 use WP_Query;
15 +use WP_REST_Request;
7 16 use WPDeveloper\BetterDocs\Utils\Base;
8 17 use WPDeveloper\BetterDocs\Utils\Helper;
9 18
10 19 class FAQBuilder extends Base {
@@ -16,9 +25,63 @@
16 25 public $post_type = 'betterdocs_faq';
17 26 public $category = 'betterdocs_faq_category';
18 27
19 28 /**
29 + * Product FAQ groups taxonomy. Shares the betterdocs_faq post type with the
30 + * General FAQ groups but is a separate taxonomy so the two never mix in the
31 + * admin or on the front end. Used by the WooCommerce Product FAQ tab.
20 32 *
33 + * @var string
34 + */
35 + public $product_category = 'betterdocs_product_faq_category';
36 +
37 + /**
38 + * Post meta recording which FAQ Builder tab an FAQ belongs to: 'product' or
39 + * 'general'.
40 + *
41 + * A General and a Product FAQ are the same post type, distinguished only by which
42 + * taxonomy their group lives in — so an FAQ with NO group had no scope at all, and
43 + * an ungrouped Product FAQ (its group deleted, or created without one) was
44 + * indistinguishable from a General one. That's why it used to surface under the
45 + * General tab's "Uncategorized". Recording the scope on the post lets each tab own
46 + * its own Uncategorized bucket.
47 + */
48 + const SCOPE_META = '_betterdocs_faq_scope';
49 +
50 + /** Option flag for the one-time backfill of SCOPE_META on pre-existing FAQs. */
51 + const SCOPE_BACKFILL_OPTION = 'betterdocs_faq_scope_backfilled';
52 +
53 + /**
54 + * Term meta on a Product FAQ group holding the product_cat term IDs the
55 + * group is assigned to. Products in those categories inherit the group.
56 + */
57 + const GROUP_PRODUCT_CATS_META = '_betterdocs_faq_group_product_cats';
58 +
59 + /**
60 + * Term meta on a Product FAQ group holding the individual product IDs the
61 + * group is assigned to directly.
62 + */
63 + const GROUP_PRODUCTS_META = '_betterdocs_faq_group_products';
64 +
65 + /**
66 + * Term meta flagging a Product FAQ group as shown on every product (no
67 + * per-product/per-category targeting). Set on the store-aware sample groups
68 + * so they render storefront-wide immediately; cleared the moment the owner
69 + * saves explicit category/product assignments.
70 + */
71 + const GROUP_ALL_PRODUCTS_META = '_betterdocs_faq_group_all_products';
72 +
73 + /**
74 + * Per-request cache of draft FAQ counts keyed by taxonomy → [ term_id => count ].
75 + * Lets the betterdocs_draft_count REST field resolve every term from a single
76 + * grouped query instead of one WP_Query per term (N+1).
77 + *
78 + * @var array<string, array<int, int>>
79 + */
80 + protected $draft_count_cache = [];
81 +
82 + /**
83 + *
21 84 * Initialize the class and start calling our hooks and filters
22 85 *
23 86 * @since 1.0.0
24 87 *
@@ -27,12 +90,97 @@
27 90 // assign default admin capabilities for docs, doc terms, doc tags, knowledge base
28 91 add_action( 'init', [ $this, 'register_post' ] );
29 92 // fires after a new betterdocs_faq_category is created
30 93 add_action( 'created_betterdocs_faq_category', [ $this, 'action_created_betterdocs_faq_category' ], 10, 2 );
94 + add_action( 'created_betterdocs_product_faq_category', [ $this, 'action_created_betterdocs_faq_category' ], 10, 2 );
31 95 add_action( 'rest_api_init', [ $this, 'register_api_endpoint' ] );
96 + add_action( 'rest_api_init', [ $this, 'register_category_count_fields' ] );
97 +
98 + // Keep each FAQ's scope ('general' | 'product') in sync however its group is
99 + // assigned — the Builder, the sample-FAQ generator, an import, or the classic
100 + // post editor all end up here — so an FAQ that later loses its group is still
101 + // known to belong to its own tab's "Uncategorized" bucket.
102 + add_action( 'set_object_terms', [ $this, 'sync_faq_scope_meta' ], 10, 4 );
103 + // One-time backfill for FAQs that predate the scope meta.
104 + add_action( 'admin_init', [ $this, 'maybe_backfill_faq_scope' ] );
32 105 add_action( 'rest_betterdocs_faq_category_query', [ $this, 'faq_category_orderby_meta' ], 10, 2 );
106 + add_action( 'rest_betterdocs_product_faq_category_query', [ $this, 'faq_category_orderby_meta' ], 10, 2 );
107 +
108 + // Classic FAQ Group list (edit-tags.php?taxonomy=betterdocs_faq_category):
109 + // enable drag-and-drop ordering + persist it, mirroring the classic Doc
110 + // Categories screen.
111 + add_action( 'admin_enqueue_scripts', [ $this, 'classic_category_scripts' ] );
112 + add_action( 'wp_ajax_update_faq_cat_order', [ $this, 'ajax_update_category_order' ] );
113 + add_action( 'admin_head', [ $this, 'order_admin_terms' ] );
33 114 }
34 115
116 + /**
117 + * Expose a per-group draft FAQ count on the betterdocs_faq_category REST
118 + * response so the FAQ Builder can show "N questions • M draft". The term
119 + * `count` only tracks published FAQs (via _update_post_term_count), so the
120 + * draft count is computed here.
121 + *
122 + * @since 4.4.0
123 + */
124 + public function register_category_count_fields() {
125 + foreach ( [ $this->category, $this->product_category ] as $taxonomy ) {
126 + register_rest_field(
127 + $taxonomy,
128 + 'betterdocs_draft_count',
129 + [
130 + 'get_callback' => function ( $term ) {
131 + $counts = $this->get_draft_counts_by_term( $term['taxonomy'] );
132 + return isset( $counts[ (int) $term['id'] ] ) ? (int) $counts[ (int) $term['id'] ] : 0;
133 + },
134 + 'schema' => [
135 + 'type' => 'integer',
136 + 'context' => [ 'view', 'edit' ],
137 + ],
138 + ]
139 + );
140 + }
141 + }
142 +
143 + /**
144 + * Draft FAQ counts for every term in a taxonomy, keyed by term_id.
145 + *
146 + * Computed once per request with a single grouped query and memoized, so the
147 + * betterdocs_draft_count REST field no longer fires a WP_Query per term when
148 + * a category list is assembled (avoids the N+1 on large group counts).
149 + *
150 + * @param string $taxonomy
151 + * @return array<int, int>
152 + */
153 + protected function get_draft_counts_by_term( $taxonomy ) {
154 + if ( isset( $this->draft_count_cache[ $taxonomy ] ) ) {
155 + return $this->draft_count_cache[ $taxonomy ];
156 + }
157 +
158 + global $wpdb;
159 +
160 + $rows = $wpdb->get_results(
161 + $wpdb->prepare(
162 + "SELECT tt.term_id, COUNT( p.ID ) AS draft_count
163 + FROM {$wpdb->posts} p
164 + INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
165 + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
166 + WHERE p.post_type = %s
167 + AND p.post_status = 'draft'
168 + AND tt.taxonomy = %s
169 + GROUP BY tt.term_id",
170 + $this->post_type,
171 + $taxonomy
172 + )
173 + ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
174 +
175 + $counts = [];
176 + foreach ( (array) $rows as $row ) {
177 + $counts[ (int) $row->term_id ] = (int) $row->draft_count;
178 + }
179 +
180 + return $this->draft_count_cache[ $taxonomy ] = $counts;
181 + }
182 +
35 183 public function output() {
36 184 betterdocs()->views->get( 'admin/faq-builder' );
37 185 }
38 186
@@ -84,8 +232,62 @@
84 232 register_term_meta( $this->category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
85 233 register_term_meta( $this->category, 'faq_group_icon', [ 'show_in_rest' => true ]);
86 234
87 235 /**
236 + * Register the Product FAQ groups taxonomy (WooCommerce tab). It mirrors
237 + * the General FAQ category taxonomy and shares the betterdocs_faq post
238 + * type, but is kept separate so Product FAQ groups never appear in the
239 + * General FAQ Builder tab.
240 + */
241 + $product_category_labels = [
242 + 'name' => __( 'Product FAQ Categories', 'betterdocs' ),
243 + 'singular_name' => __( 'Product FAQ Category', 'betterdocs' ),
244 + 'all_items' => __( 'Product FAQ Categories', 'betterdocs' ),
245 + 'parent_item' => __( 'Parent Product FAQ Category', 'betterdocs' ),
246 + 'parent_item_colon' => __( 'Parent Product FAQ Category:', 'betterdocs' ),
247 + 'edit_item' => __( 'Edit Product FAQ Category', 'betterdocs' ),
248 + 'update_item' => __( 'Update Product FAQ Category', 'betterdocs' ),
249 + 'add_new_item' => __( 'Add New Product FAQ Category', 'betterdocs' ),
250 + 'new_item_name' => __( 'New Product FAQ Category Name', 'betterdocs' ),
251 + 'menu_name' => __( 'Product FAQ Categories', 'betterdocs' )
252 + ];
253 +
254 + $product_category_args = $category_args;
255 + $product_category_args['labels'] = $product_category_labels;
256 + $product_category_args['show_admin_column'] = false;
257 +
258 + register_taxonomy( $this->product_category, [ $this->post_type ], $product_category_args );
259 + register_term_meta( $this->product_category, 'order', [ 'show_in_rest' => true ] );
260 + register_term_meta( $this->product_category, 'status', [ 'show_in_rest' => true ] );
261 + register_term_meta( $this->product_category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
262 + register_term_meta( $this->product_category, 'faq_group_icon', [ 'show_in_rest' => true ] );
263 +
264 + // Product FAQ group assignments: which WooCommerce product categories and
265 + // which individual products this group is shown on. Stored on the group
266 + // term so the assignment lives in the Create/Update FAQ Group screen.
267 + $assignment_meta_args = [
268 + 'single' => true,
269 + 'type' => 'array',
270 + 'show_in_rest' => [
271 + 'schema' => [
272 + 'type' => 'array',
273 + 'items' => [ 'type' => 'integer' ],
274 + ],
275 + ],
276 + ];
277 + register_term_meta( $this->product_category, self::GROUP_PRODUCT_CATS_META, $assignment_meta_args );
278 + register_term_meta( $this->product_category, self::GROUP_PRODUCTS_META, $assignment_meta_args );
279 + register_term_meta(
280 + $this->product_category,
281 + self::GROUP_ALL_PRODUCTS_META,
282 + [
283 + 'single' => true,
284 + 'type' => 'boolean',
285 + 'show_in_rest' => true,
286 + ]
287 + );
288 +
289 + /**
88 290 * Register post type
89 291 */
90 292 $labels = [
91 293 'name' => __( 'BetterDocs FAQ', 'betterdocs' ),
@@ -132,9 +334,11 @@
132 334 *
133 335 * @param string $tax_slug The taxonomy's slug.
134 336 */
135 337 public function action_created_betterdocs_faq_category( $term_id ) {
136 - $order = $this->get_max_taxonomy_order( 'betterdocs_faq_category' );
338 + $term = get_term( $term_id );
339 + $taxonomy = ( $term && ! is_wp_error( $term ) ) ? $term->taxonomy : $this->category;
340 + $order = $this->get_max_taxonomy_order( $taxonomy );
137 341 update_term_meta( $term_id, 'order', $order++ );
138 342 update_term_meta( $term_id, 'status', 1 );
139 343 }
140 344
@@ -164,14 +368,14 @@
164 368 * Get the maximum order for this taxonomy. This will be applied to terms that don't have a tax position.
165 369 */
166 370 private function get_max_taxonomy_order( $tax_slug ) {
167 371 global $wpdb;
168 - // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder
372 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live max-order needed when assigning new terms; cache would be stale.
169 373 $max_term_order = $wpdb->get_col(
170 374 $wpdb->prepare(
171 375 "SELECT MAX( CAST( tm.meta_value AS UNSIGNED ) )
172 376 FROM $wpdb->terms t
173 - JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = '%s'
377 + JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = %s
174 378 JOIN $wpdb->termmeta tm ON tm.term_id = t.term_id WHERE tm.meta_key = 'order'",
175 379 $tax_slug
176 380 )
177 381 );
@@ -188,8 +392,15 @@
188 392 * @param array $taxonomies Array of taxonomy names.
189 393 * @param array $args Array of term query args.
190 394 */
191 395 public function set_tax_order( $pieces, $taxonomies, $args ) {
396 + // Only force the manual drag-drop `order` meta when the FAQ Builder
397 + // header dropdown is on "default". For name/count/id sort modes, let
398 + // get_terms() keep its own orderby so the front end matches the builder.
399 + if ( betterdocs()->query->get_faq_order_key() !== 'default' ) {
400 + return $pieces;
401 + }
402 +
192 403 foreach ( $taxonomies as $taxonomy ) {
193 404 global $wpdb;
194 405
195 406 if ( $taxonomy === 'betterdocs_faq_category' ) {
@@ -226,8 +437,169 @@
226 437 protected function does_substring_exist( $string, $substring ) {
227 438 return strstr( $string, $substring ) !== false;
228 439 }
229 440
441 + /**
442 + * Load the shared drag-and-drop sorter on the classic FAQ Group list
443 + * (edit-tags.php?taxonomy=betterdocs_faq_category). Reuses the generic,
444 + * config-driven admin/js/category-edit.js — the same script the classic Doc
445 + * Categories screen uses — pointed at the FAQ category ordering AJAX action.
446 + *
447 + * jquery + jquery-ui-sortable are declared explicitly so `.sortable()` is
448 + * always defined (the bare script reported jQuery/Sortable as undefined here
449 + * because no sorter was enqueued on this screen at all).
450 + *
451 + * @param string $hook Current admin page hook.
452 + */
453 + public function classic_category_scripts( $hook ) {
454 + if ( 'edit-tags.php' !== $hook ) {
455 + return;
456 + }
457 +
458 + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
459 + if ( ! $screen || $screen->taxonomy !== $this->category ) {
460 + return;
461 + }
462 +
463 + betterdocs()->assets->enqueue(
464 + 'betterdocs-category-edit',
465 + 'admin/js/category-edit.js',
466 + [ 'jquery', 'jquery-ui-sortable' ]
467 + );
468 +
469 + betterdocs()->assets->localize(
470 + 'betterdocs-category-edit',
471 + 'betterdocsCategorySorting',
472 + [
473 + 'action' => 'update_faq_cat_order',
474 + 'selector' => '.taxonomy-' . $this->category,
475 + 'ajaxurl' => admin_url( 'admin-ajax.php' ),
476 + 'nonce' => wp_create_nonce( 'faq_cat_order_nonce' ),
477 + // Paged only sets the ordering base offset (the AJAX write itself
478 + // is nonce-protected), so read it directly from the WP term-list
479 + // pagination link so cross-page ordering stays correct.
480 + 'paged' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 0, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
481 + 'per_page_id' => "edit_{$this->category}_per_page",
482 + ]
483 + );
484 + }
485 +
486 + /**
487 + * Persist FAQ Group order from the classic drag-and-drop sorter. Writes the
488 + * `order` term meta that the React FAQ Builder and the front end already read,
489 + * so all three stay in sync. Mirrors PostType::update_category_order.
490 + */
491 + public function ajax_update_category_order() {
492 + if ( ! check_ajax_referer( 'faq_cat_order_nonce', 'nonce', false ) ) {
493 + wp_send_json_error( __( 'Nonce Failed', 'betterdocs' ) );
494 + }
495 +
496 + if ( ! $this->can_manage_faqs() ) {
497 + wp_send_json_error( __( 'You don\'t have permission to manage FAQ groups.', 'betterdocs' ) );
498 + }
499 +
500 + $base_index = isset( $_POST['base_index'] ) ? intval( $_POST['base_index'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
501 +
502 + if ( isset( $_POST['data'] ) && is_array( $_POST['data'] ) ) {
503 + $ordering = array_filter(
504 + array_map(
505 + function ( $item ) {
506 + if ( is_array( $item ) && isset( $item['term_id'], $item['order'] ) ) {
507 + return [
508 + 'term_id' => intval( $item['term_id'] ),
509 + 'order' => intval( $item['order'] ),
510 + ];
511 + }
512 + return null;
513 + },
514 + wp_unslash( $_POST['data'] ) // phpcs:ignore
515 + )
516 + );
517 + } else {
518 + $ordering = [];
519 + }
520 +
521 + foreach ( $ordering as $order_data ) {
522 + update_term_meta( $order_data['term_id'], 'order', $order_data['order'] + $base_index );
523 + }
524 +
525 + wp_send_json_success( __( 'Successfully updated.', 'betterdocs' ) );
526 + }
527 +
528 + /**
529 + * Order the classic FAQ Group list by the manual `order` term meta so the
530 + * drag-and-drop order persists across reloads. Seeds the meta for any terms
531 + * missing it first (e.g. groups created before ordering existed). Mirrors
532 + * PostType::order_terms for the Doc Categories screen.
533 + */
534 + public function order_admin_terms() {
535 + global $current_screen;
536 +
537 + if (
538 + ! isset( $_GET['orderby'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended
539 + $current_screen &&
540 + ! empty( $current_screen->base ) &&
541 + $current_screen->base === 'edit-tags' &&
542 + $current_screen->taxonomy === $this->category
543 + ) {
544 + $this->default_term_order( $this->category );
545 + add_filter( 'terms_clauses', [ $this, 'admin_order_terms_clauses' ], 10, 3 );
546 + }
547 + }
548 +
549 + /**
550 + * terms_clauses filter (classic FAQ Group admin list only): force ORDER BY
551 + * the `order` term meta. Unlike set_tax_order this is not gated on the
552 + * builder's saved order key — the classic screen is always manual ordering.
553 + */
554 + public function admin_order_terms_clauses( $pieces, $taxonomies, $args ) {
555 + if ( ! in_array( $this->category, (array) $taxonomies, true ) ) {
556 + return $pieces;
557 + }
558 +
559 + global $wpdb;
560 + $join_statement = " LEFT JOIN $wpdb->termmeta AS bd_faq_order ON t.term_id = bd_faq_order.term_id AND bd_faq_order.meta_key = 'order'";
561 +
562 + if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
563 + $pieces['join'] .= $join_statement;
564 + }
565 +
566 + $pieces['orderby'] = 'ORDER BY CAST( bd_faq_order.meta_value AS UNSIGNED )';
567 +
568 + return $pieces;
569 + }
570 +
571 + /**
572 + * Whether the current user may manage FAQ groups and FAQs.
573 + *
574 + * Two capabilities, either of which is enough. Nothing that could reach
575 + * these routes before can be turned away now — the gate only widens.
576 + *
577 + * - `edit_others_posts` is the original gate, kept verbatim for backward
578 + * compatibility: every site that granted FAQ Builder access by granting a
579 + * core editor-level role keeps working exactly as it did.
580 + * - `edit_others_docs` is the BetterDocs-side answer, added in 4.9.0. The
581 + * `betterdocs_faq` post type is registered with
582 + * `capability_type => [ 'doc', 'docs' ]` and `map_meta_cap => true`, so
583 + * WordPress already governs individual FAQ posts with the docs capability
584 + * family — the routes were the one place that asked for a *core* post
585 + * capability instead. A documentation-only role (BetterDocs' own `editor`
586 + * bucket, or anything Pro's `article_roles` grants) could edit every FAQ
587 + * post through `wp/v2/betterdocs_faq` and still get a 403 from the FAQ
588 + * Builder's own API.
589 + *
590 + * It is also what lines the REST routes up with the MCP FAQ abilities, which
591 + * are gated on `edit_others_docs`: an agent that may create an FAQ through
592 + * `bd-create-faq` reaches these same routes underneath.
593 + *
594 + * @since 4.9.0
595 + *
596 + * @return bool
597 + */
598 + private function can_manage_faqs() {
599 + return current_user_can( 'edit_others_posts' ) || current_user_can( 'edit_others_docs' );
600 + }
601 +
230 602 public function register_api_endpoint() {
231 603 register_rest_route(
232 604 $this->namespace,
233 605 '/faq/sample_data',
@@ -234,9 +606,9 @@
234 606 [
235 607 'methods' => [ 'POST' ],
236 608 'callback' => [ $this, 'create_faq_sample' ],
237 609 'permission_callback' => function () {
238 - return current_user_can( 'edit_others_posts' );
610 + return $this->can_manage_faqs();
239 611 }
240 612 ]
241 613 );
242 614
@@ -246,9 +618,9 @@
246 618 [
247 619 'methods' => [ 'GET' ],
248 620 'callback' => [ $this, 'fetch_faq_posts' ],
249 621 'permission_callback' => function () {
250 - return current_user_can( 'edit_others_posts' );
622 + return $this->can_manage_faqs();
251 623 }
252 624 ]
253 625 );
254 626
@@ -258,9 +630,9 @@
258 630 [
259 631 'methods' => [ 'POST' ],
260 632 'callback' => [ $this, 'create_faq_category' ],
261 633 'permission_callback' => function () {
262 - return current_user_can( 'edit_others_posts' );
634 + return $this->can_manage_faqs();
263 635 }
264 636 ]
265 637 );
266 638
@@ -270,9 +642,9 @@
270 642 [
271 643 'methods' => [ 'POST' ],
272 644 'callback' => [ $this, 'update_faq_category' ],
273 645 'permission_callback' => function () {
274 - return current_user_can( 'edit_others_posts' );
646 + return $this->can_manage_faqs();
275 647 }
276 648 ]
277 649 );
278 650
@@ -282,9 +654,9 @@
282 654 [
283 655 'methods' => [ 'POST' ],
284 656 'callback' => [ $this, 'delete_faq_category' ],
285 657 'permission_callback' => function () {
286 - return current_user_can( 'edit_others_posts' );
658 + return $this->can_manage_faqs();
287 659 }
288 660 ]
289 661 );
290 662
@@ -294,9 +666,9 @@
294 666 [
295 667 'methods' => [ 'POST' ],
296 668 'callback' => [ $this, 'create_betterdocs_faq' ],
297 669 'permission_callback' => function () {
298 - return current_user_can( 'edit_others_posts' );
670 + return $this->can_manage_faqs();
299 671 }
300 672 ]
301 673 );
302 674
@@ -306,9 +678,9 @@
306 678 [
307 679 'methods' => [ 'POST' ],
308 680 'callback' => [ $this, 'update_betterdocs_faq' ],
309 681 'permission_callback' => function () {
310 - return current_user_can( 'edit_others_posts' );
682 + return $this->can_manage_faqs();
311 683 }
312 684 ]
313 685 );
314 686
@@ -318,9 +690,9 @@
318 690 [
319 691 'methods' => [ 'POST' ],
320 692 'callback' => [ $this, 'delete_betterdocs_faq' ],
321 693 'permission_callback' => function () {
322 - return current_user_can( 'edit_others_posts' );
694 + return $this->can_manage_faqs();
323 695 }
324 696 ]
325 697 );
326 698
@@ -330,9 +702,9 @@
330 702 [
331 703 'methods' => [ 'POST' ],
332 704 'callback' => [ $this, 'update_category_status' ],
333 705 'permission_callback' => function () {
334 - return current_user_can( 'edit_others_posts' );
706 + return $this->can_manage_faqs();
335 707 }
336 708 ]
337 709 );
338 710
@@ -342,9 +714,9 @@
342 714 [
343 715 'methods' => [ 'POST' ],
344 716 'callback' => [ $this, 'update_faq_category_order' ],
345 717 'permission_callback' => function () {
346 - return current_user_can( 'edit_others_posts' );
718 + return $this->can_manage_faqs();
347 719 }
348 720 ]
349 721 );
350 722
@@ -354,9 +726,9 @@
354 726 [
355 727 'methods' => [ 'POST' ],
356 728 'callback' => [ $this, 'update_faq_order_by_category' ],
357 729 'permission_callback' => function () {
358 - return current_user_can( 'edit_others_posts' );
730 + return $this->can_manage_faqs();
359 731 }
360 732 ]
361 733 );
362 734
@@ -361,14 +733,26 @@
361 733 );
362 734
363 735 register_rest_route(
364 736 $this->namespace,
737 + '/faq/order',
738 + [
739 + 'methods' => [ 'POST' ],
740 + 'callback' => [ $this, 'update_faq_order_preference' ],
741 + 'permission_callback' => function () {
742 + return $this->can_manage_faqs();
743 + }
744 + ]
745 + );
746 +
747 + register_rest_route(
748 + $this->namespace,
365 749 '/faq/uncategorised',
366 750 [
367 751 'methods' => [ 'GET' ],
368 752 'callback' => [ $this, 'get_uncategorised_faq' ],
369 753 'permission_callback' => function () {
370 - return current_user_can( 'edit_others_posts' );
754 + return $this->can_manage_faqs();
371 755 }
372 756 ]
373 757 );
374 758
@@ -378,9 +762,9 @@
378 762 [
379 763 'methods' => [ 'GET' ],
380 764 'callback' => [ $this, 'category_search' ],
381 765 'permission_callback' => function () {
382 - return current_user_can( 'edit_others_posts' );
766 + return $this->can_manage_faqs();
383 767 },
384 768 'args' => [
385 769 'title' => [
386 770 'type' => 'string',
@@ -385,8 +769,13 @@
385 769 'title' => [
386 770 'type' => 'string',
387 771 'required' => true,
388 772 'sanitize_callback' => 'sanitize_text_field'
773 + ],
774 + 'taxonomy' => [
775 + 'type' => 'string',
776 + 'required' => false,
777 + 'sanitize_callback' => 'sanitize_text_field'
389 778 ]
390 779 ]
391 780 ]
392 781 );
@@ -407,27 +796,63 @@
407 796 }
408 797 return true;
409 798 }
410 799
800 + /**
801 + * Resolve the taxonomy from a request, whitelisted to the General and
802 + * Product FAQ category taxonomies. Defaults to the General taxonomy so
803 + * existing callers (which never send a taxonomy) keep their behavior.
804 + *
805 + * @param \WP_REST_Request $params
806 + * @return string
807 + */
808 + private function resolve_taxonomy( $params ) {
809 + $taxonomy = $params->get_param( 'taxonomy' );
810 + return in_array( $taxonomy, [ $this->category, $this->product_category ], true )
811 + ? $taxonomy
812 + : $this->category;
813 + }
814 +
411 815 public function create_faq_category( $params ) {
412 - $title = $params->get_param( 'title' );
816 + // The WP term `name` column is varchar(200); cap the length so an
817 + // over-long title can't trigger a DB insert error (the React form caps
818 + // this too, this is the server-side guard).
819 + $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
413 820 $description = $params->get_param( 'description' );
414 821 $description = ( $description !== 'undefined' ) ? $description : '';
415 822 $slug = $params->get_param( 'slug' );
416 823 $group_icon_url = $params->get_param('group_icon_url');
417 - return $this->insert_betterdocs_faq_category( $title, $description, $group_icon_url, $slug );
824 + $taxonomy = $this->resolve_taxonomy( $params );
825 + $result = $this->insert_betterdocs_faq_category(
826 + $title,
827 + $description,
828 + $group_icon_url,
829 + $slug,
830 + $taxonomy,
831 + (array) $params->get_param( 'product_cats' ),
832 + (array) $params->get_param( 'products' ),
833 + $this->all_products_param( $params )
834 + );
835 +
836 + if ( is_wp_error( $result ) ) {
837 + return $result;
838 + }
839 +
840 + return rest_ensure_response( array( 'success' => true, 'term_id' => (int) $result ) );
418 841 }
419 842
420 843 public function update_faq_category( $params ) {
421 844 $term_id = $params->get_param( 'term_id' );
422 - $title = $params->get_param( 'title' );
845 + // Cap at the varchar(200) term-name limit (server-side guard).
846 + $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
423 847 $description = $params->get_param( 'description' );
424 848 $group_icon_url = $params->get_param('group_icon_url');
425 849 $description = ( $description !== 'undefined' ) ? $description : '';
426 850 $slug = $params->get_param( 'slug' );
851 + $taxonomy = $this->resolve_taxonomy( $params );
427 852 $update = wp_update_term(
428 853 $term_id,
429 - 'betterdocs_faq_category',
854 + $taxonomy,
430 855 [
431 856 'name' => $title,
432 857 'slug' => $slug,
433 858 'description' => $description
@@ -440,8 +865,15 @@
440 865 $term_id = isset( $update['term_id'] ) ? $update['term_id'] : 0;
441 866 if( $term_id != 0 ) {
442 867 $previous_icon_url = get_term_meta($term_id, 'faq_group_icon', true);
443 868 update_term_meta($term_id, 'faq_group_icon', $group_icon_url, $previous_icon_url);
869 + $this->save_group_assignments(
870 + $term_id,
871 + $taxonomy,
872 + (array) $params->get_param( 'product_cats' ),
873 + (array) $params->get_param( 'products' ),
874 + $this->all_products_param( $params )
875 + );
444 876 }
445 877 return true;
446 878 }
447 879 }
@@ -448,14 +880,15 @@
448 880
449 881 public function delete_faq_category( $params ) {
450 882 $term_id = $params->get_param( 'term_id' );
451 883 $delete_all_docs = $params->get_param('with_all_post');
884 + $taxonomy = $this->resolve_taxonomy( $params );
452 885
453 886 if ( $delete_all_docs ) {
454 - Helper::delete_specific_faq_posts_by_faq_category( $term_id );
887 + Helper::delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy );
455 888 }
456 889
457 - $delete = wp_delete_term( $term_id, 'betterdocs_faq_category' );
890 + $delete = wp_delete_term( $term_id, $taxonomy );
458 891
459 892 if ( is_wp_error( $delete ) ) {
460 893 return $delete;
461 894 } else {
@@ -462,12 +895,12 @@
462 895 return true;
463 896 }
464 897 }
465 898
466 - public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '' ) {
899 + public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '', $taxonomy = 'betterdocs_faq_category', $product_cats = [], $products = [], $all_products = false ) {
467 900 $insert_term = wp_insert_term(
468 901 $title,
469 - 'betterdocs_faq_category',
902 + $taxonomy,
470 903 [
471 904 'slug' => $slug,
472 905 'description' => $description
473 906 ]
@@ -478,13 +911,89 @@
478 911 } else {
479 912 $term_id = isset( $insert_term['term_id'] ) ? $insert_term['term_id'] : 0;
480 913 if( $term_id != 0 ) {
481 914 update_term_meta($term_id, 'faq_group_icon', $icon_url);
915 + $this->save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products );
482 916 }
483 - return true;
917 + // Return the new term id so the admin can jump to its pagination
918 + // page and highlight it (mirrors the new-FAQ reveal flow).
919 + return (int) $term_id;
484 920 }
485 921 }
486 922
923 + /**
924 + * Persist a Product FAQ group's targeting. Three mutually-exclusive scopes:
925 + * - all products → GROUP_ALL_PRODUCTS_META, shown on every product page;
926 + * - specific cats/products → GROUP_PRODUCT_CATS_META / GROUP_PRODUCTS_META;
927 + * - none → the group is hidden on the storefront.
928 + *
929 + * No-op for any taxonomy other than the Product FAQ groups taxonomy.
930 + *
931 + * @param int $term_id
932 + * @param string $taxonomy
933 + * @param array $product_cats Incoming product_cat term IDs.
934 + * @param array $products Incoming product post IDs.
935 + * @param bool $all_products When true, show on ALL products and clear the cat/product
936 + * targets (the scopes are mutually exclusive).
937 + */
938 + private function save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products = false ) {
939 + if ( $taxonomy !== $this->product_category ) {
940 + return;
941 + }
942 +
943 + // "Show on all products" wins and clears the specific targets, so a store-wide
944 + // group (e.g. the generated "Shipping, Returns & Payments") can be switched to
945 + // specific categories/products and back, from the same modal.
946 + if ( $all_products ) {
947 + update_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META, true );
948 + delete_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META );
949 + delete_term_meta( $term_id, self::GROUP_PRODUCTS_META );
950 + return;
951 + }
952 +
953 + $cat_ids = [];
954 + foreach ( (array) $product_cats as $cid ) {
955 + $cid = (int) $cid;
956 + if ( $cid > 0 && term_exists( $cid, 'product_cat' ) ) {
957 + $cat_ids[] = $cid;
958 + }
959 + }
960 + $cat_ids = array_values( array_unique( $cat_ids ) );
961 +
962 + $product_ids = [];
963 + foreach ( (array) $products as $pid ) {
964 + $pid = (int) $pid;
965 + if ( $pid > 0 && 'product' === get_post_type( $pid ) ) {
966 + $product_ids[] = $pid;
967 + }
968 + }
969 + $product_ids = array_values( array_unique( $product_ids ) );
970 +
971 + if ( empty( $cat_ids ) ) {
972 + delete_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META );
973 + } else {
974 + update_term_meta( $term_id, self::GROUP_PRODUCT_CATS_META, $cat_ids );
975 + }
976 +
977 + if ( empty( $product_ids ) ) {
978 + delete_term_meta( $term_id, self::GROUP_PRODUCTS_META );
979 + } else {
980 + update_term_meta( $term_id, self::GROUP_PRODUCTS_META, $product_ids );
981 + }
982 +
983 + // Explicitly not "all products" → drop the match-all flag.
984 + delete_term_meta( $term_id, self::GROUP_ALL_PRODUCTS_META );
985 + }
986 +
987 + /**
988 + * Read + normalize the `all_products` REST param (accepts '1'/'0'/true/false).
989 + *
990 + * @return bool
991 + */
992 + private function all_products_param( $params ) {
993 + return filter_var( $params->get_param( 'all_products' ), FILTER_VALIDATE_BOOLEAN );
994 + }
995 +
487 996 public function update_faq_category_order( $params ) {
488 997 $faq_category_order = $params->get_param( 'faq_category_order' );
489 998 $faq_category_order = json_decode( $faq_category_order, true );
490 999
@@ -495,9 +1004,9 @@
495 1004 }
496 1005 return true;
497 1006 }
498 1007
499 - public function insert_betterdocs_faq( $post_title, $post_content, $term_id ) {
1008 + public function insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy = 'betterdocs_faq_category' ) {
500 1009 $post = wp_insert_post(
501 1010 [
502 1011 'post_type' => 'betterdocs_faq',
503 1012 'post_title' => wp_strip_all_tags( $post_title ),
@@ -505,31 +1014,42 @@
505 1014 'post_status' => 'publish'
506 1015 ]
507 1016 );
508 1017
1018 + // Stamp the scope from the tab this FAQ was created in. Doing it here (and not
1019 + // only in sync_faq_scope_meta) is what makes an FAQ created WITHOUT a group land
1020 + // in the right tab's "Uncategorized" — with no terms assigned, set_object_terms
1021 + // never fires.
1022 + if ( $post && ! is_wp_error( $post ) ) {
1023 + update_post_meta(
1024 + $post,
1025 + self::SCOPE_META,
1026 + $taxonomy === $this->product_category ? 'product' : 'general'
1027 + );
1028 + }
1029 +
509 1030 if ( $term_id ) {
510 - $set_terms = wp_set_object_terms( $post, $term_id, 'betterdocs_faq_category' );
1031 + $set_terms = wp_set_object_terms( $post, $term_id, $taxonomy );
511 1032 if ( is_wp_error( $set_terms ) ) {
512 1033 return $set_terms;
513 - } else {
514 - return $this->update_faq_order_on_insert( $term_id, $post );
515 1034 }
516 - } else {
517 - return $post;
1035 + $this->update_faq_order_on_insert( $term_id, $post );
518 1036 }
1037 +
1038 + // Always return the new post ID so the admin can reveal/highlight the
1039 + // created FAQ (the grouped path previously returned the term-meta result).
1040 + return $post;
519 1041 }
520 1042
521 1043 public function update_faq_order_on_insert( $term_id, $post ) {
522 - $term_meta = get_term_meta( $term_id, '_betterdocs_faq_order' );
523 - if ( ! empty( $term_meta ) ) {
524 - $term_meta_arr = explode( ',', $term_meta[0] );
525 - if ( ! in_array( $post, $term_meta_arr ) ) {
526 - array_unshift( $term_meta_arr, $post );
527 - $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
528 - return update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
529 - }
530 - } else {
531 - return update_term_meta( $term_id, '_betterdocs_faq_order', $post );
1044 + // QA-008: read the single stored value and strip blanks so the very first
1045 + // insert (no existing meta) doesn't explode(',', null) into a corrupt [''].
1046 + $existing = (string) get_term_meta( $term_id, '_betterdocs_faq_order', true );
1047 + $term_meta_arr = array_filter( array_map( 'trim', explode( ',', $existing ) ), 'strlen' );
1048 + if ( ! in_array( (string) $post, $term_meta_arr, true ) ) {
1049 + array_unshift( $term_meta_arr, $post );
1050 + $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
1051 + return update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
532 1052 }
533 1053 }
534 1054
535 1055 /**
@@ -541,21 +1061,48 @@
541 1061 $posts = $params->get_param( 'posts' );
542 1062 return update_term_meta( $term_id, '_betterdocs_faq_order', $posts );
543 1063 }
544 1064
1065 + /**
1066 + * Persist the FAQ Builder header order dropdown so the front end can mirror
1067 + * it. Stored as a single global preference in the `betterdocs_faq_order`
1068 + * option and read back by Query::get_faq_order_key().
1069 + */
1070 + public function update_faq_order_preference( $params ) {
1071 + $order = $params->get_param( 'order' );
1072 + $allowed = array( 'default', 'most_recent', 'least_recent', 'a_to_z', 'z_to_a', 'most_questions' );
1073 +
1074 + if ( ! in_array( $order, $allowed, true ) ) {
1075 + $order = 'default';
1076 + }
1077 +
1078 + update_option( 'betterdocs_faq_order', $order );
1079 +
1080 + return rest_ensure_response( array( 'success' => true, 'order' => $order ) );
1081 + }
1082 +
545 1083 public function create_betterdocs_faq( $params ) {
546 1084 $post_title = $params->get_param( 'post_title' );
547 1085 $post_content = $params->get_param( 'post_content' );
548 1086 $term_id = $params->get_param( 'term_id' );
549 - return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id );
1087 + $taxonomy = $this->resolve_taxonomy( $params );
1088 + return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy );
550 1089 }
551 1090
552 1091 public function update_betterdocs_faq( $params ) {
553 - $post_id = $params->get_param( 'post_id' );
1092 + $post_id = (int) $params->get_param( 'post_id' );
1093 +
1094 + // QA-001: never let an arbitrary post ID be retyped/overwritten through
1095 + // this endpoint. Only operate on posts that are already BetterDocs FAQs.
1096 + if ( ! $post_id || get_post_type( $post_id ) !== $this->post_type ) {
1097 + return new WP_Error( 'betterdocs_invalid_faq', __( 'Invalid FAQ ID.', 'betterdocs' ), [ 'status' => 404 ] );
1098 + }
1099 +
554 1100 $post_title = $params->get_param( 'post_title' );
555 1101 $post_content = $params->get_param( 'post_content' );
556 1102 $status = $params->get_param( 'status' );
557 - $term_id = $params->get_param( 'term_id' );
1103 + $term_id = (int) $params->get_param( 'term_id' );
1104 + $taxonomy = $this->resolve_taxonomy( $params );
558 1105 if ( $status ) {
559 1106 $data = [
560 1107 'post_type' => 'betterdocs_faq',
561 1108 'ID' => $post_id,
@@ -564,20 +1111,30 @@
564 1111 } else {
565 1112 $data = [
566 1113 'post_type' => 'betterdocs_faq',
567 1114 'ID' => $post_id,
568 - 'post_title' => $post_title,
569 - 'post_content' => $post_content
1115 + // QA-009: sanitize server-side — the editor posts raw TinyMCE
1116 + // HTML. Title is plain text; content keeps post-safe HTML only
1117 + // (scripts / event handlers stripped) even for unfiltered_html users.
1118 + 'post_title' => sanitize_text_field( $post_title ),
1119 + 'post_content' => wp_kses_post( $post_content ),
570 1120 ];
571 1121
572 - if ( $term_id ) {
1122 + // QA-007: only assign a term that actually exists in the resolved FAQ
1123 + // taxonomy — an invalid term_id would silently orphan the FAQ.
1124 + if ( $term_id && term_exists( $term_id, $taxonomy ) ) {
573 1125 $data['tax_input'] = [
574 - 'betterdocs_faq_category' => $term_id
1126 + $taxonomy => [ $term_id ],
575 1127 ];
576 1128
577 - $term_meta = get_term_meta( $term_id, '_betterdocs_faq_order' );
578 - $term_meta_arr = explode( ',', $term_meta[0] );
579 - if ( ! in_array( $post_id, $term_meta_arr ) ) {
1129 + // QA-008: build the order list defensively. On the first update the
1130 + // term has no _betterdocs_faq_order meta; the old code did
1131 + // explode(',', null) → [''] which corrupted the order with a blank
1132 + // entry (plus a PHP 8.1 "null to explode" deprecation). Read the
1133 + // single value and drop blanks instead.
1134 + $existing = (string) get_term_meta( $term_id, '_betterdocs_faq_order', true );
1135 + $term_meta_arr = array_filter( array_map( 'trim', explode( ',', $existing ) ), 'strlen' );
1136 + if ( ! in_array( (string) $post_id, $term_meta_arr, true ) ) {
580 1137 array_unshift( $term_meta_arr, $post_id );
581 1138 $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
582 1139 update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
583 1140 }
@@ -587,9 +1144,15 @@
587 1144 return wp_update_post( $data );
588 1145 }
589 1146
590 1147 public function delete_betterdocs_faq( $params ) {
591 - $post_id = $params->get_param( 'post_id' );
1148 + $post_id = (int) $params->get_param( 'post_id' );
1149 +
1150 + // QA-001: refuse to delete anything that isn't a BetterDocs FAQ.
1151 + if ( ! $post_id || get_post_type( $post_id ) !== $this->post_type ) {
1152 + return new WP_Error( 'betterdocs_invalid_faq', __( 'Invalid FAQ ID.', 'betterdocs' ), [ 'status' => 404 ] );
1153 + }
1154 +
592 1155 return wp_delete_post( $post_id );
593 1156 }
594 1157
595 1158 public function faq_post_loop( $args ) {
@@ -625,8 +1188,9 @@
625 1188 );
626 1189
627 1190 if ( $taxonomy_objects && ! is_wp_error( $taxonomy_objects ) ) :
628 1191 foreach ( $taxonomy_objects as $term ) :
1192 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- core FAQ-by-category query; tax filtering is required functionality.
629 1193 $args = [
630 1194 'post_type' => 'betterdocs_faq',
631 1195 'post_status' => 'publish',
632 1196 'post_per_page' => -1,
@@ -659,47 +1223,172 @@
659 1223
660 1224 return $faq;
661 1225 }
662 1226
663 - public function get_uncategorised_faq() {
664 - $available_terms = array_map(
665 - function ( $term ) {
666 - return $term->term_id;
667 - },
668 - get_terms(
1227 + /**
1228 + * Record an FAQ's scope whenever its group is (re)assigned, so the scope survives
1229 + * the group being deleted. Fires for every path that assigns terms.
1230 + *
1231 + * @param int $object_id Post ID.
1232 + * @param array $terms Terms assigned (unused).
1233 + * @param array $tt_ids Term-taxonomy IDs.
1234 + * @param string $taxonomy Taxonomy the terms belong to.
1235 + */
1236 + public function sync_faq_scope_meta( $object_id, $terms, $tt_ids, $taxonomy ) {
1237 + if ( get_post_type( $object_id ) !== $this->post_type ) {
1238 + return;
1239 + }
1240 +
1241 + if ( $taxonomy === $this->product_category && ! empty( $tt_ids ) ) {
1242 + update_post_meta( $object_id, self::SCOPE_META, 'product' );
1243 + return;
1244 + }
1245 +
1246 + // Assigning a General group makes it a General FAQ — but only claim it if it
1247 + // isn't already a Product FAQ that merely also carries a General term.
1248 + if ( $taxonomy === $this->category && ! empty( $tt_ids ) ) {
1249 + if ( get_post_meta( $object_id, self::SCOPE_META, true ) !== 'product' ) {
1250 + update_post_meta( $object_id, self::SCOPE_META, 'general' );
1251 + }
1252 + }
1253 + }
1254 +
1255 + /**
1256 + * One-time backfill: stamp the scope onto FAQs created before SCOPE_META existed.
1257 + * An FAQ holding a Product group is 'product'; everything else is 'general'.
1258 + * Ungrouped legacy FAQs have no way to be identified as product ones, so they stay
1259 + * in the General tab, which is exactly where they are today.
1260 + */
1261 + public function maybe_backfill_faq_scope() {
1262 + if ( get_option( self::SCOPE_BACKFILL_OPTION ) ) {
1263 + return;
1264 + }
1265 +
1266 + $product_faqs = get_posts(
1267 + [
1268 + 'post_type' => $this->post_type,
1269 + 'post_status' => 'any',
1270 + 'posts_per_page' => -1,
1271 + 'fields' => 'ids',
1272 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- one-time backfill.
1273 + 'tax_query' => [
1274 + [
1275 + 'taxonomy' => $this->product_category,
1276 + 'operator' => 'EXISTS',
1277 + ],
1278 + ],
1279 + ]
1280 + );
1281 +
1282 + foreach ( $product_faqs as $faq_id ) {
1283 + update_post_meta( $faq_id, self::SCOPE_META, 'product' );
1284 + }
1285 +
1286 + update_option( self::SCOPE_BACKFILL_OPTION, 1 );
1287 + }
1288 +
1289 + /**
1290 + * FAQs with no group, for the tab that asked. Each tab owns its own bucket:
1291 + * the Product tab lists ungrouped FAQs scoped to 'product', the General tab lists
1292 + * everything else that is ungrouped (so an ungrouped Product FAQ no longer leaks
1293 + * into General).
1294 + */
1295 + public function get_uncategorised_faq( $request = null ) {
1296 + $taxonomy = $request instanceof WP_REST_Request ? $this->resolve_taxonomy( $request ) : $this->category;
1297 + $is_product = ( $taxonomy === $this->product_category );
1298 +
1299 + $term_ids = function ( $tax ) {
1300 + $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => false ] );
1301 + return is_wp_error( $terms ) ? [] : array_map(
1302 + function ( $term ) {
1303 + return $term->term_id;
1304 + },
1305 + $terms
1306 + );
1307 + };
1308 +
1309 + // "Ungrouped" always means: no group in THIS tab's taxonomy.
1310 + $tax_query = [
1311 + 'relation' => 'AND',
1312 + [
1313 + 'taxonomy' => $taxonomy,
1314 + 'field' => 'term_id',
1315 + 'terms' => $term_ids( $taxonomy ),
1316 + 'operator' => 'NOT IN'
1317 + ]
1318 + ];
1319 +
1320 + $meta_query = [];
1321 +
1322 + if ( $is_product ) {
1323 + // Product tab: only FAQs that belong to the Product side.
1324 + $meta_query[] = [
1325 + 'key' => self::SCOPE_META,
1326 + 'value' => 'product',
1327 + ];
1328 + } else {
1329 + // General tab: exclude anything scoped to Product…
1330 + $meta_query[] = [
1331 + 'relation' => 'OR',
669 1332 [
670 - 'taxonomy' => 'betterdocs_faq_category',
671 - 'hide_empty' => false
672 - ]
673 - )
674 - );
1333 + 'key' => self::SCOPE_META,
1334 + 'compare' => 'NOT EXISTS',
1335 + ],
1336 + [
1337 + 'key' => self::SCOPE_META,
1338 + 'value' => 'product',
1339 + 'compare' => '!=',
1340 + ],
1341 + ];
675 1342
676 - return get_posts(
1343 + // …and, as before, anything still holding a Product group (belt and braces
1344 + // for FAQs whose scope meta never got written).
1345 + $product_terms = $term_ids( $this->product_category );
1346 + if ( ! empty( $product_terms ) ) {
1347 + $tax_query[] = [
1348 + 'taxonomy' => $this->product_category,
1349 + 'field' => 'term_id',
1350 + 'terms' => $product_terms,
1351 + 'operator' => 'NOT IN'
1352 + ];
1353 + }
1354 + }
1355 +
1356 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query, WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- intentional NOT-IN scan to find FAQs without any group.
1357 + $posts = get_posts(
677 1358 [
678 1359 'post_type' => 'betterdocs_faq',
679 - 'post_status' => current_user_can( 'edit_others_posts' ) ? [ 'publish', 'draft' ] : 'publish',
1360 + 'post_status' => $this->can_manage_faqs() ? [ 'publish', 'draft' ] : 'publish',
680 1361 'posts_per_page' => -1,
681 - 'tax_query' => [
682 - 'relation' => 'AND',
683 - [
684 - 'taxonomy' => 'betterdocs_faq_category',
685 - 'field' => 'term_id',
686 - 'terms' => $available_terms,
687 - 'operator' => 'NOT IN'
688 - ]
689 - ]
1362 + 'tax_query' => $tax_query,
1363 + 'meta_query' => $meta_query,
690 1364 ]
691 1365 );
1366 +
1367 + // get_posts() returns raw WP_Post objects without a `meta` property. The
1368 + // FAQ Builder reads `faq.meta.faq_open_by_default` to seed the "Keep It
1369 + // Open By default" switcher, so attach it here to mirror the scalar shape
1370 + // the wp/v2 endpoint returns for categorized FAQs ('' when unset, '1'
1371 + // when enabled). Without this the value is undefined and the switcher
1372 + // defaults to ON for every uncategorized FAQ.
1373 + foreach ( $posts as $post ) {
1374 + $post->meta = [
1375 + 'faq_open_by_default' => get_post_meta( $post->ID, 'faq_open_by_default', true ),
1376 + ];
1377 + }
1378 +
1379 + return $posts;
692 1380 }
693 1381
694 1382 public function category_search( $request ) {
695 1383
696 - $title = $request['title'];
1384 + $title = $request['title'];
1385 + $taxonomy = $this->resolve_taxonomy( $request );
697 1386
698 1387 // Perform the taxonomy search
699 1388 $taxonomy_args = [
700 1389 'name__like' => $title,
701 - 'taxonomy' => 'betterdocs_faq_category',
1390 + 'taxonomy' => $taxonomy,
702 1391 'hide_empty' => false
703 1392 ];
704 1393
705 1394 $taxonomies = get_terms( $taxonomy_args );
@@ -724,11 +1413,56 @@
724 1413 }
725 1414 }
726 1415
727 1416 public function faq_category_orderby_meta( $args, $request ) {
728 - if ( $args['taxonomy'] === 'betterdocs_faq_category' ) {
729 - $args['orderby'] = 'meta_value_num';
730 - $args['meta_key'] = 'order';
1417 + if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1418 + return $args;
731 1419 }
1420 +
1421 + // Order (and therefore paginate) the whole group list server-side to
1422 + // match the FAQ Builder header dropdown, so page boundaries are cut on
1423 + // the same sort the rows are shown in — otherwise page 1 could end on
1424 + // "W" and page 2 start on "B". The admin passes the order key explicitly
1425 + // (independent of when the preference option finishes saving); fall back
1426 + // to the saved preference, then to the manual drag-drop order.
1427 + $order_key = $request->get_param( 'betterdocs_faq_order' );
1428 + if ( empty( $order_key ) ) {
1429 + $order_key = get_option( 'betterdocs_faq_order', 'default' );
1430 + }
1431 +
1432 + switch ( $order_key ) {
1433 + case 'most_recent':
1434 + $args['orderby'] = 'term_id';
1435 + $args['order'] = 'DESC';
1436 + unset( $args['meta_key'] );
1437 + break;
1438 + case 'least_recent':
1439 + $args['orderby'] = 'term_id';
1440 + $args['order'] = 'ASC';
1441 + unset( $args['meta_key'] );
1442 + break;
1443 + case 'a_to_z':
1444 + $args['orderby'] = 'name';
1445 + $args['order'] = 'ASC';
1446 + unset( $args['meta_key'] );
1447 + break;
1448 + case 'z_to_a':
1449 + $args['orderby'] = 'name';
1450 + $args['order'] = 'DESC';
1451 + unset( $args['meta_key'] );
1452 + break;
1453 + case 'most_questions':
1454 + $args['orderby'] = 'count';
1455 + $args['order'] = 'DESC';
1456 + unset( $args['meta_key'] );
1457 + break;
1458 + case 'default':
1459 + default:
1460 + $args['orderby'] = 'meta_value_num';
1461 + $args['meta_key'] = 'order';
1462 + $args['order'] = 'ASC';
1463 + break;
1464 + }
1465 +
732 1466 return $args;
733 1467 }
734 1468 }