PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.1
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 3.5.2 All 199 releases
betterdocs / includes / Core / FAQBuilder.php

FAQBuilder.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.1, at includes/Core/FAQBuilder.php

1,469 lines 48.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace WPDeveloper\BetterDocs\Core;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
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
13 use WP_Error;
14 use WP_Query;
15 use WP_REST_Request;
16 use WPDeveloper\BetterDocs\Utils\Base;
17 use WPDeveloper\BetterDocs\Utils\Helper;
18
19 class FAQBuilder extends Base {
20 /**
21 * REST API namespace
22 * @var string
23 */
24 private $namespace = 'betterdocs';
25 public $post_type = 'betterdocs_faq';
26 public $category = 'betterdocs_faq_category';
27
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.
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 *
84 * Initialize the class and start calling our hooks and filters
85 *
86 * @since 1.0.0
87 *
88 */
89 public function __construct() {
90 // assign default admin capabilities for docs, doc terms, doc tags, knowledge base
91 add_action( 'init', [ $this, 'register_post' ] );
92 // fires after a new betterdocs_faq_category is created
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 );
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' ] );
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' ] );
114 }
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
183 public function output() {
184 betterdocs()->views->get( 'admin/faq-builder' );
185 }
186
187 /**
188 *
189 * Register post type and taxonomies
190 *
191 * @since 1.0.0
192 *
193 */
194 public function register_post() {
195 /**
196 * Register category taxonomy
197 */
198 $category_labels = [
199 'name' => __( 'FAQ Categories', 'betterdocs' ),
200 'singular_name' => __( 'FAQ Category', 'betterdocs' ),
201 'all_items' => __( 'FAQ Categories', 'betterdocs' ),
202 'parent_item' => __( 'Parent FAQ Category', 'betterdocs' ),
203 'parent_item_colon' => __( 'Parent FAQ Category:', 'betterdocs' ),
204 'edit_item' => __( 'Edit Category', 'betterdocs' ),
205 'update_item' => __( 'Update Category', 'betterdocs' ),
206 'add_new_item' => __( 'Add New FAQ Category', 'betterdocs' ),
207 'new_item_name' => __( 'New FAQ Category Name', 'betterdocs' ),
208 'menu_name' => __( 'Categories', 'betterdocs' )
209 ];
210
211 $category_args = [
212 'hierarchical' => true,
213 'public' => false,
214 'labels' => $category_labels,
215 'show_ui' => true,
216 'show_admin_column' => true,
217 'query_var' => true,
218 'show_in_rest' => true,
219 'has_archive' => false,
220 'rewrite' => false,
221 'capabilities' => [
222 'manage_terms' => 'manage_doc_terms',
223 'edit_terms' => 'edit_doc_terms',
224 'delete_terms' => 'delete_doc_terms',
225 'assign_terms' => 'edit_docs'
226 ]
227 ];
228
229 register_taxonomy( $this->category, [ $this->post_type ], $category_args );
230 register_term_meta( $this->category, 'order', [ 'show_in_rest' => true ] );
231 register_term_meta( $this->category, 'status', [ 'show_in_rest' => true ] );
232 register_term_meta( $this->category, '_betterdocs_faq_order', [ 'show_in_rest' => true ] );
233 register_term_meta( $this->category, 'faq_group_icon', [ 'show_in_rest' => true ]);
234
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 /**
290 * Register post type
291 */
292 $labels = [
293 'name' => __( 'BetterDocs FAQ', 'betterdocs' ),
294 'singular_name' => __( 'BetterDocs FAQ', 'betterdocs' ),
295 'menu_name' => __( 'FAQ', 'betterdocs' ),
296 'name_admin_bar' => __( 'FAQ', 'betterdocs' ),
297 'add_new' => __( 'Add New', 'betterdocs' ),
298 'add_new_item' => __( 'Add New FAQ', 'betterdocs' ),
299 'new_item' => __( 'New FAQ', 'betterdocs' ),
300 'edit_item' => __( 'Edit FAQ', 'betterdocs' ),
301 'view_item' => __( 'View FAQ', 'betterdocs' ),
302 'all_items' => __( 'All FAQ', 'betterdocs' ),
303 'search_items' => __( 'Search FAQ', 'betterdocs' ),
304 'parent_item_colorn' => null,
305 'not_found' => __( 'No FAQ found', 'betterdocs' ),
306 'not_found_in_trash' => __( 'No FAQ found in trash', 'betterdocs' )
307 ];
308
309 $args = [
310 'labels' => $labels,
311 'description' => __( 'Add new faq from here', 'betterdocs' ),
312 'public' => false,
313 'public_queryable' => true,
314 'exclude_from_search' => false,
315 'show_ui' => true,
316 'show_in_menu' => false,
317 'query_var' => true,
318 'capability_type' => [ 'doc', 'docs' ],
319 'hierarchical' => true,
320 'map_meta_cap' => true,
321 'has_archive' => false,
322 'rewrite' => false,
323 'show_in_rest' => true,
324 'menu_icon' => betterdocs()->assets->icon( 'betterdocs-icon-white.svg' ),
325 'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'author', 'revisions', 'custom-fields', 'comments' ]
326 ];
327
328 register_post_type( $this->post_type, $args );
329 register_post_meta( $this->post_type, 'faq_open_by_default', [ 'show_in_rest' => true, 'single' => true ] );
330 }
331
332 /**
333 * Default the taxonomy's terms' order if it's not set.
334 *
335 * @param string $tax_slug The taxonomy's slug.
336 */
337 public function action_created_betterdocs_faq_category( $term_id ) {
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 );
341 update_term_meta( $term_id, 'order', $order++ );
342 update_term_meta( $term_id, 'status', 1 );
343 }
344
345 /**
346 * Default the taxonomy's terms' order if it's not set.
347 *
348 * @param string $tax_slug The taxonomy's slug.
349 */
350 public function default_term_order( $tax_slug ) {
351 $terms = get_terms(
352 [
353 'taxonomy' => $tax_slug,
354 'hide_empty' => false,
355 ]
356 );
357 $order = $this->get_max_taxonomy_order( $tax_slug );
358
359 foreach ( $terms as $term ) {
360 if ( ! get_term_meta( $term->term_id, 'order', true ) ) {
361 update_term_meta( $term->term_id, 'order', $order );
362 ++$order;
363 }
364 }
365 }
366
367 /**
368 * Get the maximum order for this taxonomy. This will be applied to terms that don't have a tax position.
369 */
370 private function get_max_taxonomy_order( $tax_slug ) {
371 global $wpdb;
372 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live max-order needed when assigning new terms; cache would be stale.
373 $max_term_order = $wpdb->get_col(
374 $wpdb->prepare(
375 "SELECT MAX( CAST( tm.meta_value AS UNSIGNED ) )
376 FROM $wpdb->terms t
377 JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id AND tt.taxonomy = %s
378 JOIN $wpdb->termmeta tm ON tm.term_id = t.term_id WHERE tm.meta_key = 'order'",
379 $tax_slug
380 )
381 );
382
383 $max_term_order = is_array( $max_term_order ) ? current( $max_term_order ) : 0;
384
385 return (int) $max_term_order === 0 || empty( $max_term_order ) ? 1 : (int) $max_term_order + 1;
386 }
387
388 /**
389 * Re-Order the taxonomies based on the order value.
390 *
391 * @param array $pieces Array of SQL query clauses.
392 * @param array $taxonomies Array of taxonomy names.
393 * @param array $args Array of term query args.
394 */
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
403 foreach ( $taxonomies as $taxonomy ) {
404 global $wpdb;
405
406 if ( $taxonomy === 'betterdocs_faq_category' ) {
407 $join_statement = " LEFT JOIN $wpdb->termmeta AS term_meta ON t.term_id = term_meta.term_id AND term_meta.meta_key = 'order'";
408
409 if ( ! $this->does_substring_exist( $pieces['join'], $join_statement ) ) {
410 $pieces['join'] .= $join_statement;
411 }
412
413 $pieces['orderby'] = 'ORDER BY CAST( term_meta.meta_value AS UNSIGNED )';
414 }
415 }
416
417 return $pieces;
418 }
419
420 /**
421 * Order the taxonomies on the front end.
422 */
423 public function front_end_order_terms() {
424 if ( ! is_admin() ) {
425 add_filter( 'terms_clauses', [ $this, 'set_tax_order' ], 10, 3 );
426 }
427 }
428
429 /**
430 * Check if a substring exists inside a string.
431 *
432 * @param string $string The main string (haystack) we're searching in.
433 * @param string $substring The substring we're searching for.
434 *
435 * @return bool True if substring exists, else false.
436 */
437 protected function does_substring_exist( $string, $substring ) {
438 return strstr( $string, $substring ) !== false;
439 }
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
602 public function register_api_endpoint() {
603 register_rest_route(
604 $this->namespace,
605 '/faq/sample_data',
606 [
607 'methods' => [ 'POST' ],
608 'callback' => [ $this, 'create_faq_sample' ],
609 'permission_callback' => function () {
610 return $this->can_manage_faqs();
611 }
612 ]
613 );
614
615 register_rest_route(
616 $this->namespace,
617 '/faq/posts/(?P<type>\S+)',
618 [
619 'methods' => [ 'GET' ],
620 'callback' => [ $this, 'fetch_faq_posts' ],
621 'permission_callback' => function () {
622 return $this->can_manage_faqs();
623 }
624 ]
625 );
626
627 register_rest_route(
628 $this->namespace,
629 '/faq/create_category',
630 [
631 'methods' => [ 'POST' ],
632 'callback' => [ $this, 'create_faq_category' ],
633 'permission_callback' => function () {
634 return $this->can_manage_faqs();
635 }
636 ]
637 );
638
639 register_rest_route(
640 $this->namespace,
641 '/faq/update_category',
642 [
643 'methods' => [ 'POST' ],
644 'callback' => [ $this, 'update_faq_category' ],
645 'permission_callback' => function () {
646 return $this->can_manage_faqs();
647 }
648 ]
649 );
650
651 register_rest_route(
652 $this->namespace,
653 '/faq/delete_category',
654 [
655 'methods' => [ 'POST' ],
656 'callback' => [ $this, 'delete_faq_category' ],
657 'permission_callback' => function () {
658 return $this->can_manage_faqs();
659 }
660 ]
661 );
662
663 register_rest_route(
664 $this->namespace,
665 '/faq/create_post',
666 [
667 'methods' => [ 'POST' ],
668 'callback' => [ $this, 'create_betterdocs_faq' ],
669 'permission_callback' => function () {
670 return $this->can_manage_faqs();
671 }
672 ]
673 );
674
675 register_rest_route(
676 $this->namespace,
677 '/faq/update_post',
678 [
679 'methods' => [ 'POST' ],
680 'callback' => [ $this, 'update_betterdocs_faq' ],
681 'permission_callback' => function () {
682 return $this->can_manage_faqs();
683 }
684 ]
685 );
686
687 register_rest_route(
688 $this->namespace,
689 '/faq/delete_post',
690 [
691 'methods' => [ 'POST' ],
692 'callback' => [ $this, 'delete_betterdocs_faq' ],
693 'permission_callback' => function () {
694 return $this->can_manage_faqs();
695 }
696 ]
697 );
698
699 register_rest_route(
700 $this->namespace,
701 '/faq/category_status',
702 [
703 'methods' => [ 'POST' ],
704 'callback' => [ $this, 'update_category_status' ],
705 'permission_callback' => function () {
706 return $this->can_manage_faqs();
707 }
708 ]
709 );
710
711 register_rest_route(
712 $this->namespace,
713 '/faq/category_order',
714 [
715 'methods' => [ 'POST' ],
716 'callback' => [ $this, 'update_faq_category_order' ],
717 'permission_callback' => function () {
718 return $this->can_manage_faqs();
719 }
720 ]
721 );
722
723 register_rest_route(
724 $this->namespace,
725 '/faq/update_order_by_category',
726 [
727 'methods' => [ 'POST' ],
728 'callback' => [ $this, 'update_faq_order_by_category' ],
729 'permission_callback' => function () {
730 return $this->can_manage_faqs();
731 }
732 ]
733 );
734
735 register_rest_route(
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,
749 '/faq/uncategorised',
750 [
751 'methods' => [ 'GET' ],
752 'callback' => [ $this, 'get_uncategorised_faq' ],
753 'permission_callback' => function () {
754 return $this->can_manage_faqs();
755 }
756 ]
757 );
758
759 register_rest_route(
760 $this->namespace,
761 '/faq/category_search',
762 [
763 'methods' => [ 'GET' ],
764 'callback' => [ $this, 'category_search' ],
765 'permission_callback' => function () {
766 return $this->can_manage_faqs();
767 },
768 'args' => [
769 'title' => [
770 'type' => 'string',
771 'required' => true,
772 'sanitize_callback' => 'sanitize_text_field'
773 ],
774 'taxonomy' => [
775 'type' => 'string',
776 'required' => false,
777 'sanitize_callback' => 'sanitize_text_field'
778 ]
779 ]
780 ]
781 );
782 }
783
784 public function create_faq_sample( $params ) {
785 $sample_data = json_decode( $params->get_param( 'sample_data' ), true );
786 foreach ( $sample_data as $key => $value ) {
787 $insert_term = wp_insert_term(
788 $key,
789 'betterdocs_faq_category'
790 );
791 if ( $insert_term ) {
792 foreach ( $value['posts'] as $key => $value ) {
793 $this->insert_betterdocs_faq( $value['post_title'], $value['post_content'], $insert_term['term_id'] );
794 }
795 }
796 }
797 return true;
798 }
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
815 public function create_faq_category( $params ) {
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 );
820 $description = $params->get_param( 'description' );
821 $description = ( $description !== 'undefined' ) ? $description : '';
822 $slug = $params->get_param( 'slug' );
823 $group_icon_url = $params->get_param('group_icon_url');
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 ) );
841 }
842
843 public function update_faq_category( $params ) {
844 $term_id = $params->get_param( 'term_id' );
845 // Cap at the varchar(200) term-name limit (server-side guard).
846 $title = mb_substr( (string) $params->get_param( 'title' ), 0, 200 );
847 $description = $params->get_param( 'description' );
848 $group_icon_url = $params->get_param('group_icon_url');
849 $description = ( $description !== 'undefined' ) ? $description : '';
850 $slug = $params->get_param( 'slug' );
851 $taxonomy = $this->resolve_taxonomy( $params );
852 $update = wp_update_term(
853 $term_id,
854 $taxonomy,
855 [
856 'name' => $title,
857 'slug' => $slug,
858 'description' => $description
859 ]
860 );
861
862 if ( is_wp_error( $update ) ) {
863 return $update;
864 } else {
865 $term_id = isset( $update['term_id'] ) ? $update['term_id'] : 0;
866 if( $term_id != 0 ) {
867 $previous_icon_url = get_term_meta($term_id, 'faq_group_icon', true);
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 );
876 }
877 return true;
878 }
879 }
880
881 public function delete_faq_category( $params ) {
882 $term_id = $params->get_param( 'term_id' );
883 $delete_all_docs = $params->get_param('with_all_post');
884 $taxonomy = $this->resolve_taxonomy( $params );
885
886 if ( $delete_all_docs ) {
887 Helper::delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy );
888 }
889
890 $delete = wp_delete_term( $term_id, $taxonomy );
891
892 if ( is_wp_error( $delete ) ) {
893 return $delete;
894 } else {
895 return true;
896 }
897 }
898
899 public function insert_betterdocs_faq_category( $title, $description, $icon_url, $slug = '', $taxonomy = 'betterdocs_faq_category', $product_cats = [], $products = [], $all_products = false ) {
900 $insert_term = wp_insert_term(
901 $title,
902 $taxonomy,
903 [
904 'slug' => $slug,
905 'description' => $description
906 ]
907 );
908
909 if ( is_wp_error( $insert_term ) ) {
910 return $insert_term;
911 } else {
912 $term_id = isset( $insert_term['term_id'] ) ? $insert_term['term_id'] : 0;
913 if( $term_id != 0 ) {
914 update_term_meta($term_id, 'faq_group_icon', $icon_url);
915 $this->save_group_assignments( $term_id, $taxonomy, $product_cats, $products, $all_products );
916 }
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;
920 }
921 }
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
996 public function update_faq_category_order( $params ) {
997 $faq_category_order = $params->get_param( 'faq_category_order' );
998 $faq_category_order = json_decode( $faq_category_order, true );
999
1000 foreach ( $faq_category_order as $order_data ) {
1001 if ( (int) $order_data['current_position'] != (int) $order_data['updated_position'] ) {
1002 update_term_meta( $order_data['id'], 'order', ( (int) $order_data['updated_position'] ) );
1003 }
1004 }
1005 return true;
1006 }
1007
1008 public function insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy = 'betterdocs_faq_category' ) {
1009 $post = wp_insert_post(
1010 [
1011 'post_type' => 'betterdocs_faq',
1012 'post_title' => wp_strip_all_tags( $post_title ),
1013 'post_content' => $post_content,
1014 'post_status' => 'publish'
1015 ]
1016 );
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
1030 if ( $term_id ) {
1031 $set_terms = wp_set_object_terms( $post, $term_id, $taxonomy );
1032 if ( is_wp_error( $set_terms ) ) {
1033 return $set_terms;
1034 }
1035 $this->update_faq_order_on_insert( $term_id, $post );
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;
1041 }
1042
1043 public function update_faq_order_on_insert( $term_id, $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 ) );
1052 }
1053 }
1054
1055 /**
1056 * Update _betterdocs_faq_order meta when new post created
1057 */
1058
1059 public function update_faq_order_by_category( $params ) {
1060 $term_id = $params->get_param( 'term_id' );
1061 $posts = $params->get_param( 'posts' );
1062 return update_term_meta( $term_id, '_betterdocs_faq_order', $posts );
1063 }
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
1083 public function create_betterdocs_faq( $params ) {
1084 $post_title = $params->get_param( 'post_title' );
1085 $post_content = $params->get_param( 'post_content' );
1086 $term_id = $params->get_param( 'term_id' );
1087 $taxonomy = $this->resolve_taxonomy( $params );
1088 return $this->insert_betterdocs_faq( $post_title, $post_content, $term_id, $taxonomy );
1089 }
1090
1091 public function update_betterdocs_faq( $params ) {
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
1100 $post_title = $params->get_param( 'post_title' );
1101 $post_content = $params->get_param( 'post_content' );
1102 $status = $params->get_param( 'status' );
1103 $term_id = (int) $params->get_param( 'term_id' );
1104 $taxonomy = $this->resolve_taxonomy( $params );
1105 if ( $status ) {
1106 $data = [
1107 'post_type' => 'betterdocs_faq',
1108 'ID' => $post_id,
1109 'status' => $status
1110 ];
1111 } else {
1112 $data = [
1113 'post_type' => 'betterdocs_faq',
1114 'ID' => $post_id,
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 ),
1120 ];
1121
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 ) ) {
1125 $data['tax_input'] = [
1126 $taxonomy => [ $term_id ],
1127 ];
1128
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 ) ) {
1137 array_unshift( $term_meta_arr, $post_id );
1138 $docs_ordering_data = filter_var_array( wp_unslash( $term_meta_arr ), FILTER_SANITIZE_NUMBER_INT );
1139 update_term_meta( $term_id, '_betterdocs_faq_order', implode( ',', $docs_ordering_data ) );
1140 }
1141 }
1142 }
1143
1144 return wp_update_post( $data );
1145 }
1146
1147 public function delete_betterdocs_faq( $params ) {
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
1155 return wp_delete_post( $post_id );
1156 }
1157
1158 public function faq_post_loop( $args ) {
1159 $posts = [];
1160 $query = new WP_Query( $args );
1161 if ( $query->have_posts() ) :
1162 while ( $query->have_posts() ) :
1163 $query->the_post();
1164 $posts[ get_the_ID() ]['title'] = get_the_title();
1165 $posts[ get_the_ID() ]['content'] = get_the_content();
1166 endwhile;
1167 endif;
1168
1169 return $posts;
1170 }
1171
1172 public function update_category_status( $params ) {
1173 $term_id = $params->get_param( 'term_id' );
1174 $status = $params->get_param( 'status' );
1175 return update_term_meta( $term_id, 'status', $status );
1176 }
1177
1178 public function fetch_faq_posts( $params ) {
1179 $faq = [];
1180 $type = $params->get_param( 'type' );
1181
1182 if ( $type == 'category' ) {
1183 $taxonomy_objects = get_terms(
1184 [
1185 'taxonomy' => 'betterdocs_faq_category',
1186 'hide_empty' => false
1187 ]
1188 );
1189
1190 if ( $taxonomy_objects && ! is_wp_error( $taxonomy_objects ) ) :
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.
1193 $args = [
1194 'post_type' => 'betterdocs_faq',
1195 'post_status' => 'publish',
1196 'post_per_page' => -1,
1197 'tax_query' => [
1198 [
1199 'taxonomy' => 'betterdocs_faq_category',
1200 'field' => 'term_id',
1201 'terms' => $term->term_id
1202 ]
1203 ]
1204 ];
1205
1206 $posts = $this->faq_post_loop( $args );
1207
1208 $faq[ $term->slug ] = [
1209 (array) $term,
1210 'posts' => $posts
1211 ];
1212 endforeach;
1213 endif;
1214 } else {
1215 $args = [
1216 'post_type' => 'betterdocs_faq',
1217 'post_status' => 'publish',
1218 'post_per_page' => -1
1219 ];
1220 $posts = $this->faq_post_loop( $args );
1221 $faq['posts'] = $posts;
1222 }
1223
1224 return $faq;
1225 }
1226
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',
1332 [
1333 'key' => self::SCOPE_META,
1334 'compare' => 'NOT EXISTS',
1335 ],
1336 [
1337 'key' => self::SCOPE_META,
1338 'value' => 'product',
1339 'compare' => '!=',
1340 ],
1341 ];
1342
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(
1358 [
1359 'post_type' => 'betterdocs_faq',
1360 'post_status' => $this->can_manage_faqs() ? [ 'publish', 'draft' ] : 'publish',
1361 'posts_per_page' => -1,
1362 'tax_query' => $tax_query,
1363 'meta_query' => $meta_query,
1364 ]
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;
1380 }
1381
1382 public function category_search( $request ) {
1383
1384 $title = $request['title'];
1385 $taxonomy = $this->resolve_taxonomy( $request );
1386
1387 // Perform the taxonomy search
1388 $taxonomy_args = [
1389 'name__like' => $title,
1390 'taxonomy' => $taxonomy,
1391 'hide_empty' => false
1392 ];
1393
1394 $taxonomies = get_terms( $taxonomy_args );
1395
1396 if ( ! empty( $taxonomies ) ) {
1397 $result = [];
1398 foreach ( $taxonomies as $taxonomy ) {
1399 $result[] = [
1400 'id' => $taxonomy->term_id,
1401 'count' => $taxonomy->count,
1402 'description' => $taxonomy->description,
1403 'name' => $taxonomy->name,
1404 'slug' => $taxonomy->slug
1405 // Add more fields as needed
1406 ];
1407 }
1408 // Return the taxonomy data
1409 return $result;
1410 } else {
1411 // Taxonomy not found
1412 return new WP_Error( 'taxonomy_not_found', 'Taxonomy not found.', [ 'status' => 404 ] );
1413 }
1414 }
1415
1416 public function faq_category_orderby_meta( $args, $request ) {
1417 if ( ! in_array( $args['taxonomy'], [ $this->category, $this->product_category ], true ) ) {
1418 return $args;
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
1466 return $args;
1467 }
1468 }
1469