PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.32.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.32.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-seo-analytics-endpoint.php

class-seo-analytics-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.32.0, at includes/api/class-seo-analytics-endpoint.php

1,066 lines 39.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * SEO Analytics API Endpoints Class
5 *
6 * REST API endpoints for SEO analytics data collection, Google API integration,
7 * and AI-powered insights generation. Provides comprehensive API access to
8 * Analytics Manager functionality with proper authentication, validation,
9 * and error handling.
10 *
11 * @package ThinkRank
12 * @subpackage API
13 * @since 1.0.0
14 */
15
16 declare(strict_types=1);
17
18 namespace ThinkRank\API;
19
20 use ThinkRank\SEO\Analytics_Manager;
21 use ThinkRank\API\Traits\API_Cache;
22 use WP_REST_Controller;
23 use WP_REST_Request;
24 use WP_REST_Response;
25 use WP_Error;
26
27 // Prevent direct access
28 if (!defined('ABSPATH')) {
29 exit;
30 }
31
32 // Load API Cache trait
33 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-api-cache.php';
34
35 /**
36 * SEO Analytics API Endpoints Class
37 *
38 * Provides REST API endpoints for SEO analytics operations including
39 * Google API integration, dashboard data retrieval, SEO opportunities
40 * analysis, and connection management with proper authentication and validation.
41 *
42 * @since 1.0.0
43 */
44 class SEO_Analytics_Endpoint extends WP_REST_Controller {
45
46 use API_Cache;
47
48 /**
49 * Analytics Manager instance
50 *
51 * @since 1.0.0
52 * @var Analytics_Manager
53 */
54 private Analytics_Manager $analytics_manager;
55
56 /**
57 * API namespace
58 *
59 * @since 1.0.0
60 * @var string
61 */
62 protected $namespace = 'thinkrank/v1';
63
64 /**
65 * API resource base
66 *
67 * @since 1.0.0
68 * @var string
69 */
70 protected $rest_base = 'seo-analytics';
71
72 /**
73 * Constructor
74 *
75 * @since 1.0.0
76 * @param Analytics_Manager|null $analytics_manager Analytics manager instance
77 */
78 public function __construct(?Analytics_Manager $analytics_manager = null) {
79 $this->analytics_manager = $analytics_manager ?? new Analytics_Manager();
80
81 // Configure response caching for the live Search Console passthrough
82 // endpoints (search-totals, search-daily, branded, countries).
83 $this->set_cache_prefix('thinkrank_seo_analytics_');
84 $this->set_cache_duration(3 * HOUR_IN_SECONDS); // 3 hours
85 }
86
87 /**
88 * Register API routes
89 * Following ThinkRank endpoint registration patterns
90 *
91 * @since 1.0.0
92 */
93 public function register_routes(): void {
94 // Test Google API connections
95 register_rest_route(
96 $this->namespace,
97 '/' . $this->rest_base . '/test-connections',
98 [
99 [
100 'methods' => 'GET',
101 'callback' => [$this, 'test_connections'],
102 'permission_callback' => [$this, 'check_permissions'],
103 ]
104 ]
105 );
106
107 // Get dashboard data
108 register_rest_route(
109 $this->namespace,
110 '/' . $this->rest_base . '/dashboard',
111 [
112 [
113 'methods' => 'GET',
114 'callback' => [$this, 'get_dashboard_data'],
115 'permission_callback' => [$this, 'check_data_permissions'],
116 'args' => $this->get_dashboard_args()
117 ]
118 ]
119 );
120
121 // Get SEO opportunities
122 register_rest_route(
123 $this->namespace,
124 '/' . $this->rest_base . '/opportunities',
125 [
126 [
127 'methods' => 'GET',
128 'callback' => [$this, 'get_seo_opportunities'],
129 'permission_callback' => [$this, 'check_data_permissions'],
130 'args' => $this->get_opportunities_args()
131 ]
132 ]
133 );
134
135 // Setup Search Console verification
136 register_rest_route(
137 $this->namespace,
138 '/' . $this->rest_base . '/setup/search-console',
139 [
140 [
141 'methods' => 'POST',
142 'callback' => [$this, 'setup_search_console'],
143 'permission_callback' => [$this, 'check_permissions'],
144 'args' => $this->get_setup_args()
145 ]
146 ]
147 );
148
149 // Get indexing status
150 register_rest_route(
151 $this->namespace,
152 '/' . $this->rest_base . '/indexing-status',
153 [
154 [
155 'methods' => 'GET',
156 'callback' => [$this, 'get_indexing_status'],
157 'permission_callback' => [$this, 'check_permissions'],
158 ]
159 ]
160 );
161
162 // Refresh cached data
163 register_rest_route(
164 $this->namespace,
165 '/' . $this->rest_base . '/refresh',
166 [
167 [
168 'methods' => 'POST',
169 'callback' => [$this, 'refresh_data'],
170 'permission_callback' => [$this, 'check_permissions'],
171 ]
172 ]
173 );
174
175 // Get client status (for debugging)
176 register_rest_route(
177 $this->namespace,
178 '/' . $this->rest_base . '/status',
179 [
180 [
181 'methods' => 'GET',
182 'callback' => [$this, 'get_client_status'],
183 'permission_callback' => [$this, 'check_permissions'],
184 ]
185 ]
186 );
187
188 // ========================================
189 // SEO Intelligence Enhancement Endpoints
190 // ========================================
191
192 // Get intelligent dashboard data with trends and insights
193 register_rest_route(
194 $this->namespace,
195 '/' . $this->rest_base . '/intelligent-dashboard',
196 [
197 [
198 'methods' => 'GET',
199 'callback' => [$this, 'get_intelligent_dashboard'],
200 'permission_callback' => [$this, 'check_data_permissions'],
201 'args' => $this->get_dashboard_args()
202 ]
203 ]
204 );
205
206 // Get intelligent SEO opportunities with prioritization
207 register_rest_route(
208 $this->namespace,
209 '/' . $this->rest_base . '/intelligent-opportunities',
210 [
211 [
212 'methods' => 'GET',
213 'callback' => [$this, 'get_intelligent_opportunities'],
214 'permission_callback' => [$this, 'check_data_permissions'],
215 'args' => $this->get_opportunities_args()
216 ]
217 ]
218 );
219
220 // Get SEO insights
221 register_rest_route(
222 $this->namespace,
223 '/' . $this->rest_base . '/insights',
224 [
225 [
226 'methods' => 'GET',
227 'callback' => [$this, 'get_seo_insights'],
228 'permission_callback' => [$this, 'check_data_permissions'],
229 'args' => $this->get_dashboard_args()
230 ]
231 ]
232 );
233
234 // Get Search Console totals for custom date range
235 register_rest_route(
236 $this->namespace,
237 '/' . $this->rest_base . '/search-totals',
238 [
239 [
240 'methods' => 'GET',
241 'callback' => [$this, 'get_search_totals'],
242 'permission_callback' => [$this, 'check_data_permissions'],
243 'args' => [
244 'start_date' => [
245 'required' => true,
246 'type' => 'string',
247 'sanitize_callback' => 'sanitize_text_field',
248 'description' => 'Start date (Y-m-d)',
249 ],
250 'end_date' => [
251 'required' => true,
252 'type' => 'string',
253 'sanitize_callback' => 'sanitize_text_field',
254 'description' => 'End date (Y-m-d)',
255 ],
256 ],
257 ]
258 ]
259 );
260
261 // Get daily Search Console data (by date dimension) for chart rendering
262 register_rest_route(
263 $this->namespace,
264 '/' . $this->rest_base . '/search-daily',
265 [
266 [
267 'methods' => 'GET',
268 'callback' => [$this, 'get_search_daily'],
269 'permission_callback' => [$this, 'check_data_permissions'],
270 'args' => [
271 'date_range' => [
272 'required' => false,
273 'type' => 'string',
274 'default' => '30d',
275 'sanitize_callback' => 'sanitize_text_field',
276 'description' => 'Date range: 7d, 30d, or 90d',
277 ],
278 ],
279 ]
280 ]
281 );
282
283 // Get branded vs non-branded breakdown from Search Console
284 register_rest_route(
285 $this->namespace,
286 '/' . $this->rest_base . '/branded',
287 [
288 [
289 'methods' => 'GET',
290 'callback' => [$this, 'get_branded'],
291 'permission_callback' => [$this, 'check_data_permissions'],
292 'args' => [
293 'date_range' => [
294 'required' => false,
295 'type' => 'string',
296 'default' => '30d',
297 'sanitize_callback' => 'sanitize_text_field',
298 ],
299 'brand_name' => [
300 'required' => false,
301 'type' => 'string',
302 'default' => '',
303 'sanitize_callback' => 'sanitize_text_field',
304 ],
305 ],
306 ]
307 ]
308 );
309
310 // Get top countries from Search Console
311 register_rest_route(
312 $this->namespace,
313 '/' . $this->rest_base . '/countries',
314 [
315 [
316 'methods' => 'GET',
317 'callback' => [$this, 'get_countries'],
318 'permission_callback' => [$this, 'check_data_permissions'],
319 'args' => [
320 'date_range' => [
321 'required' => false,
322 'type' => 'string',
323 'default' => '30d',
324 'sanitize_callback' => 'sanitize_text_field',
325 'description' => 'Date range: 7d, 30d, or 90d',
326 ],
327 ],
328 ]
329 ]
330 );
331 }
332
333 /**
334 * Test Google API connections
335 * Following ThinkRank response patterns
336 *
337 * @param WP_REST_Request $request Request object
338 * @return WP_REST_Response|WP_Error Response object
339 */
340 public function test_connections(WP_REST_Request $request) {
341 try {
342 $connection_results = $this->analytics_manager->test_connections();
343
344 return new WP_REST_Response([
345 'success' => true,
346 'data' => $connection_results,
347 'message' => 'Connection tests completed'
348 ], 200);
349 } catch (\Exception $e) {
350 return new WP_Error(
351 'connection_test_failed',
352 'Connection test failed: ' . $e->getMessage(),
353 ['status' => 500]
354 );
355 }
356 }
357
358 /**
359 * Get analytics dashboard data
360 *
361 * @param WP_REST_Request $request Request object
362 * @return WP_REST_Response|WP_Error Response object
363 */
364 public function get_dashboard_data(WP_REST_Request $request) {
365 try {
366 $date_range = $request->get_param('date_range');
367 $dashboard_data = $this->analytics_manager->get_dashboard_data($date_range);
368
369 return new WP_REST_Response([
370 'success' => true,
371 'data' => $dashboard_data,
372 'message' => 'Dashboard data retrieved successfully'
373 ], 200);
374 } catch (\Exception $e) {
375 return new WP_Error(
376 'dashboard_data_failed',
377 'Failed to retrieve dashboard data: ' . $e->getMessage(),
378 ['status' => 500]
379 );
380 }
381 }
382
383 /**
384 * Get SEO opportunities
385 *
386 * @param WP_REST_Request $request Request object
387 * @return WP_REST_Response|WP_Error Response object
388 */
389 public function get_seo_opportunities(WP_REST_Request $request) {
390 try {
391 $date_range = $request->get_param('date_range');
392 $opportunities = $this->analytics_manager->get_seo_opportunities($date_range);
393
394 return new WP_REST_Response([
395 'success' => true,
396 'data' => $opportunities,
397 'message' => 'SEO opportunities retrieved successfully'
398 ], 200);
399 } catch (\Exception $e) {
400 return new WP_Error(
401 'opportunities_failed',
402 'Failed to retrieve SEO opportunities: ' . $e->getMessage(),
403 ['status' => 500]
404 );
405 }
406 }
407
408 /**
409 * Setup Search Console verification
410 *
411 * @param WP_REST_Request $request Request object
412 * @return WP_REST_Response|WP_Error Response object
413 */
414 public function setup_search_console(WP_REST_Request $request) {
415 try {
416 $site_url = $request->get_param('site_url');
417 $setup_result = $this->analytics_manager->setup_search_console_verification($site_url);
418
419 return new WP_REST_Response([
420 'success' => $setup_result['success'],
421 'data' => $setup_result,
422 'message' => $setup_result['message']
423 ], $setup_result['success'] ? 200 : 400);
424 } catch (\Exception $e) {
425 return new WP_Error(
426 'setup_failed',
427 'Search Console setup failed: ' . $e->getMessage(),
428 ['status' => 500]
429 );
430 }
431 }
432
433 /**
434 * Get indexing status
435 *
436 * @param WP_REST_Request $request Request object
437 * @return WP_REST_Response|WP_Error Response object
438 */
439 public function get_indexing_status(WP_REST_Request $request) {
440 try {
441 $indexing_status = $this->analytics_manager->get_indexing_status();
442
443 return new WP_REST_Response([
444 'success' => true,
445 'data' => $indexing_status,
446 'message' => 'Indexing status retrieved successfully'
447 ], 200);
448 } catch (\Exception $e) {
449 return new WP_Error(
450 'indexing_status_failed',
451 'Failed to retrieve indexing status: ' . $e->getMessage(),
452 ['status' => 500]
453 );
454 }
455 }
456
457 /**
458 * Refresh cached analytics data
459 *
460 * @param WP_REST_Request $request Request object
461 * @return WP_REST_Response|WP_Error Response object
462 */
463 public function refresh_data(WP_REST_Request $request) {
464 try {
465 $refresh_result = $this->analytics_manager->refresh_data();
466
467 // Bust the cached Search Console passthrough responses (search-totals,
468 // search-daily, branded, countries) so an explicit refresh re-fetches.
469 $this->invalidate_cache_pattern($this->cache_prefix . '*');
470
471 return new WP_REST_Response([
472 'success' => $refresh_result['success'],
473 'data' => $refresh_result,
474 'message' => $refresh_result['message']
475 ], 200);
476 } catch (\Exception $e) {
477 return new WP_Error(
478 'refresh_failed',
479 'Failed to refresh data: ' . $e->getMessage(),
480 ['status' => 500]
481 );
482 }
483 }
484
485 /**
486 * Get client status for debugging
487 *
488 * @param WP_REST_Request $request Request object
489 * @return WP_REST_Response|WP_Error Response object
490 */
491 public function get_client_status(WP_REST_Request $request) {
492 try {
493 $client_status = $this->analytics_manager->get_client_status();
494
495 return new WP_REST_Response([
496 'success' => true,
497 'data' => $client_status,
498 'message' => 'Client status retrieved successfully'
499 ], 200);
500 } catch (\Exception $e) {
501 return new WP_Error(
502 'status_failed',
503 'Failed to retrieve client status: ' . $e->getMessage(),
504 ['status' => 500]
505 );
506 }
507 }
508
509 /**
510 * Get dashboard endpoint arguments
511 * Following ThinkRank argument validation patterns
512 *
513 * @return array Endpoint arguments
514 */
515 private function get_dashboard_args(): array {
516 return [
517 'date_range' => [
518 'type' => 'string',
519 'default' => '30d',
520 'enum' => ['7d', '30d', '90d'],
521 'sanitize_callback' => 'sanitize_key',
522 'description' => 'Date range for analytics data'
523 ]
524 ];
525 }
526
527 /**
528 * Get opportunities endpoint arguments
529 *
530 * @return array Endpoint arguments
531 */
532 private function get_opportunities_args(): array {
533 return [
534 'date_range' => [
535 'type' => 'string',
536 'default' => '30d',
537 'enum' => ['7d', '30d', '90d'],
538 'sanitize_callback' => 'sanitize_key',
539 'description' => 'Date range for opportunities analysis'
540 ]
541 ];
542 }
543
544 /**
545 * Get setup endpoint arguments
546 *
547 * @return array Endpoint arguments
548 */
549 private function get_setup_args(): array {
550 return [
551 'site_url' => [
552 'required' => true,
553 'type' => 'string',
554 'sanitize_callback' => 'esc_url_raw',
555 'validate_callback' => [$this, 'validate_site_url'],
556 'description' => 'Site URL to verify in Search Console'
557 ]
558 ];
559 }
560
561 /**
562 * Validate site URL parameter
563 * Following ThinkRank validation patterns
564 *
565 * @param string $site_url Site URL to validate
566 * @return bool|WP_Error Validation result
567 */
568 public function validate_site_url(string $site_url) {
569 if (empty($site_url)) {
570 return new WP_Error(
571 'invalid_site_url',
572 'Site URL is required',
573 ['status' => 400]
574 );
575 }
576
577 if (!filter_var($site_url, FILTER_VALIDATE_URL)) {
578 return new WP_Error(
579 'invalid_site_url',
580 'Site URL must be a valid URL',
581 ['status' => 400]
582 );
583 }
584
585 return true;
586 }
587
588 /**
589 * Get Search Console totals for a custom date range
590 *
591 * @param WP_REST_Request $request Request object
592 * @return WP_REST_Response|WP_Error Response object
593 */
594 public function get_search_totals(WP_REST_Request $request) {
595 try {
596 $start_date = $request->get_param('start_date');
597 $end_date = $request->get_param('end_date');
598
599 // Validate date format and actual calendar validity
600 $start_dt = \DateTime::createFromFormat('Y-m-d', $start_date);
601 $end_dt = \DateTime::createFromFormat('Y-m-d', $end_date);
602 if (
603 !$start_dt || $start_dt->format('Y-m-d') !== $start_date ||
604 !$end_dt || $end_dt->format('Y-m-d') !== $end_date
605 ) {
606 return new WP_Error('invalid_dates', 'Dates must be valid calendar dates in Y-m-d format', ['status' => 400]);
607 }
608 if ($start_dt > $end_dt) {
609 return new WP_Error('invalid_dates', 'start_date must not be after end_date', ['status' => 400]);
610 }
611
612 // Use Analytics Manager to access the initialized client with decrypted credentials
613 $context = $this->resolve_search_console();
614 if (is_wp_error($context)) {
615 return $context;
616 }
617 [$search_console, $site_url] = $context;
618
619 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
620 $response = $this->cached_response(
621 'search_totals',
622 function () use ($search_console, $site_url, $start_date, $end_date) {
623 return [
624 'success' => true,
625 'data' => $search_console->get_search_totals_by_dates($site_url, $start_date, $end_date),
626 'message' => 'Search totals retrieved',
627 ];
628 },
629 ['start_date' => $start_date, 'end_date' => $end_date]
630 );
631
632 return new WP_REST_Response($response, 200);
633 } catch (\Exception $e) {
634 return $this->google_error_to_wp_error($e, 'search_totals_failed');
635 }
636 }
637
638 /**
639 * Get daily Search Console data grouped by date for chart rendering.
640 *
641 * Returns rows sorted ascending by date, each containing:
642 * clicks, impressions, ctr (as %), position.
643 *
644 * @param WP_REST_Request $request Request object
645 * @return WP_REST_Response|WP_Error Response object
646 */
647 public function get_search_daily(WP_REST_Request $request) {
648 try {
649 $date_range = $request->get_param('date_range') ?: '30d';
650 $days = (int) preg_replace('/[^0-9]/', '', $date_range);
651 if ($days <= 0 || $days > 90) {
652 $days = 30;
653 }
654
655 // Window = exactly $days back from today (inclusive of today).
656 // 7d → today-6 ... today
657 // 30d → today-29 ... today
658 // 90d → today-89 ... today
659 $end_date = gmdate('Y-m-d');
660 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days'));
661
662 $context = $this->resolve_search_console();
663 if (is_wp_error($context)) {
664 return $context;
665 }
666 [$search_console, $site_url] = $context;
667
668 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
669 $response = $this->cached_response(
670 'search_daily',
671 function () use ($search_console, $site_url, $start_date, $end_date, $days) {
672 $raw_rows = $search_console->get_search_performance_by_dates(
673 $site_url,
674 $start_date,
675 $end_date,
676 $days + 5,
677 ['date']
678 );
679
680 // Index GSC rows by date so we can pad missing days (GSC's lag means
681 // the most recent few days often have no data yet).
682 $by_date = [];
683 foreach ($raw_rows as $row) {
684 $date = $row['keys'][0] ?? '';
685 if (!$date) {
686 continue;
687 }
688 $by_date[$date] = [
689 'clicks' => (int) ($row['clicks'] ?? 0),
690 'impressions' => (int) ($row['impressions'] ?? 0),
691 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
692 'position' => round($row['position'] ?? 0, 1),
693 ];
694 }
695
696 // Build a contiguous N-day series from $start_date → $end_date.
697 // Days GSC has no data for (today minus 2-4 days, typically) come
698 // through as zeros so the chart x-axis always spans the full window.
699 $rows = [];
700 $cursor = strtotime($start_date);
701 $end_ts = strtotime($end_date);
702 while ($cursor <= $end_ts) {
703 $date = gmdate('Y-m-d', $cursor);
704 $rows[] = array_merge(
705 ['date' => $date],
706 $by_date[$date] ?? ['clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0]
707 );
708 $cursor = strtotime('+1 day', $cursor);
709 }
710
711 return [
712 'success' => true,
713 'data' => [
714 'rows' => $rows,
715 'start_date' => $start_date,
716 'end_date' => $end_date,
717 ],
718 'message' => 'Daily search data retrieved',
719 ];
720 },
721 ['date_range' => $date_range, 'start_date' => $start_date, 'end_date' => $end_date]
722 );
723
724 return new WP_REST_Response($response, 200);
725 } catch (\Exception $e) {
726 return $this->google_error_to_wp_error($e, 'search_daily_failed');
727 }
728 }
729
730 /**
731 * Get branded vs non-branded query breakdown from Search Console.
732 *
733 * Accepts optional `brand_name` param (comma-separated keywords).
734 * When omitted the brand is auto-derived from the registered domain.
735 * Also returns the equivalent previous-period data so the frontend can
736 * compute trend arrows without a second round-trip.
737 *
738 * @param WP_REST_Request $request Request object
739 * @return WP_REST_Response|WP_Error
740 */
741 public function get_branded(WP_REST_Request $request) {
742 try {
743 $date_range = $request->get_param('date_range') ?: '30d';
744 $brand_name = $request->get_param('brand_name') ?: '';
745
746 $context = $this->resolve_search_console();
747 if (is_wp_error($context)) {
748 return $context;
749 }
750 [$search_console, $site_url] = $context;
751
752 // Cache the (double) live GSC call (3h TTL, site-wide) keyed by
753 // date range + brand terms.
754 $response = $this->cached_response(
755 'branded',
756 function () use ($search_console, $site_url, $date_range, $brand_name) {
757 return [
758 'success' => true,
759 'data' => $search_console->get_branded_performance($site_url, $date_range, $brand_name),
760 'message' => 'Branded data retrieved',
761 ];
762 },
763 ['date_range' => $date_range, 'brand_name' => $brand_name]
764 );
765
766 return new WP_REST_Response($response, 200);
767 } catch (\Exception $e) {
768 return $this->google_error_to_wp_error($e, 'branded_failed');
769 }
770 }
771
772 /**
773 * Get top countries from Search Console (country dimension).
774 *
775 * Returns up to 10 countries sorted by clicks descending, each with
776 * clicks, impressions, ctr, position, and a percentage share of total clicks.
777 *
778 * @param WP_REST_Request $request Request object
779 * @return WP_REST_Response|WP_Error Response object
780 */
781 public function get_countries(WP_REST_Request $request) {
782 try {
783 $date_range = $request->get_param('date_range') ?: '30d';
784
785 $context = $this->resolve_search_console();
786 if (is_wp_error($context)) {
787 return $context;
788 }
789 [$search_console, $site_url] = $context;
790
791 // Cache the live GSC call (3h TTL, site-wide) keyed by the date range.
792 $response = $this->cached_response(
793 'countries',
794 function () use ($search_console, $site_url, $date_range) {
795 return [
796 'success' => true,
797 'data' => $search_console->get_country_performance($site_url, $date_range),
798 'message' => 'Country data retrieved',
799 ];
800 },
801 ['date_range' => $date_range]
802 );
803
804 return new WP_REST_Response($response, 200);
805 } catch (\Exception $e) {
806 return $this->google_error_to_wp_error($e, 'countries_failed');
807 }
808 }
809
810 /**
811 * Resolve the Search Console client + property URL for the live GSC routes.
812 *
813 * Both failure modes are configuration problems the site owner can fix, so
814 * they return an actionable error instead of letting the request reach
815 * Google and bounce back as raw API text — an unselected property, for
816 * example, otherwise surfaces as
817 * "Google API error (400): 'http://' is not a valid Search Console site URL".
818 *
819 * @since 1.0.0
820 * @return array{0: \ThinkRank\Integrations\Google_Search_Console_Client, 1: string}|WP_Error
821 */
822 private function resolve_search_console() {
823 $client = $this->analytics_manager->get_search_console_client();
824
825 if (!$client) {
826 return new WP_Error(
827 'google_not_connected',
828 __('Google Search Console is not connected yet. Connect your Google account to see search data here.', 'thinkrank'),
829 ['status' => 400, 'reason' => 'not_connected']
830 );
831 }
832
833 $site_url = trim((string) $this->analytics_manager->get_property_url());
834
835 // Accept only the two formats Search Console recognises: a URL-prefix
836 // property (https://example.com/) or a domain property
837 // (sc-domain:example.com). Anything else — most often an empty setting —
838 // means no verified property has been picked yet.
839 if (!preg_match('#^(sc-domain:\S+|https?://\S+)$#i', $site_url)) {
840 return new WP_Error(
841 'no_search_console_property',
842 __('No Search Console property is selected for this site. Choose your verified property to start loading search data.', 'thinkrank'),
843 ['status' => 400, 'reason' => 'no_property']
844 );
845 }
846
847 return [$client, $site_url];
848 }
849
850 /**
851 * Turn a Google API exception into an error a site owner can act on.
852 *
853 * Google's own wording ("'http://' is not a valid Search Console site URL",
854 * bare 401/403s) tells an admin nothing about what to fix, so map the common
855 * statuses to plain-language messages plus a `reason` the UI turns into the
856 * matching call to action. The raw text is preserved in `details` for
857 * debugging — these routes are already admin-gated.
858 *
859 * @since 1.0.0
860 * @param \Exception $e Exception thrown by the Google client.
861 * @param string $code WP_Error code for the failing route.
862 * @return WP_Error Actionable error.
863 */
864 private function google_error_to_wp_error(\Exception $e, string $code): WP_Error {
865 $status = (int) $e->getCode();
866 // The Google client escapes the API's message before wrapping it in the
867 // exception, so decode it back for display — the UI renders `details` as
868 // plain text, where entities would show up literally ("&#039;").
869 $raw = html_entity_decode($e->getMessage(), ENT_QUOTES, 'UTF-8');
870
871 if (stripos($raw, 'valid Search Console site URL') !== false || $status === 404) {
872 $reason = 'no_property';
873 $message = __('The Search Console property for this site is missing or no longer valid. Select your verified property again to restore search data.', 'thinkrank');
874 $http_status = 400;
875 } elseif ($status === 401) {
876 $reason = 'reconnect';
877 $message = __('Your Google connection has expired. Reconnect your Google account to load Search Console data.', 'thinkrank');
878 $http_status = 401;
879 } elseif ($status === 403) {
880 $reason = 'permission';
881 $message = __('Your Google account does not have access to this Search Console property. Verify ownership in Search Console, or select a property you own.', 'thinkrank');
882 $http_status = 403;
883 } elseif ($status === 429) {
884 $reason = 'quota';
885 $message = __('Google is rate limiting requests right now. Search data will load again shortly.', 'thinkrank');
886 $http_status = 429;
887 } elseif ($status >= 500) {
888 $reason = 'google_down';
889 $message = __('Google Search Console is temporarily unavailable. Please try again in a few minutes.', 'thinkrank');
890 $http_status = 502;
891 } else {
892 $reason = 'unknown';
893 $message = __('Search Console data could not be loaded right now. Please try again.', 'thinkrank');
894 $http_status = 502;
895 }
896
897 return new WP_Error(
898 $code,
899 $message,
900 [
901 'status' => $http_status,
902 'reason' => $reason,
903 'details' => $raw,
904 ]
905 );
906 }
907
908 /**
909 * Check permissions for API access
910 * Following ThinkRank permission patterns
911 *
912 * @return bool Permission status
913 */
914 public function check_permissions(): bool {
915 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_analytics');
916 }
917
918 /**
919 * Check permissions for the Google-backed data routes
920 *
921 * On top of the capability check, requires the SEO Analytics feature
922 * toggle to be enabled. Without this guard a disabled feature would
923 * still hit the Google APIs and surface raw errors (e.g. 401s when no
924 * Google account is connected). Settings read/write routes are not
925 * gated so the feature can always be (re-)enabled.
926 *
927 * @return bool|WP_Error True when allowed, false or WP_Error otherwise
928 */
929 public function check_data_permissions() {
930 if (!$this->check_permissions()) {
931 return false;
932 }
933
934 if (!\ThinkRank\Core\Settings::instance()->get('seo_analytics_enabled', false)) {
935 return new WP_Error(
936 'seo_analytics_disabled',
937 __('SEO Analytics is disabled. Enable it in the SEO Analytics settings to load data.', 'thinkrank'),
938 ['status' => 403]
939 );
940 }
941
942 return true;
943 }
944
945 // ========================================
946 // SEO Intelligence Enhancement Endpoints
947 // ========================================
948
949 /**
950 * Get intelligent dashboard data with trends and insights
951 *
952 * @param WP_REST_Request $request Request object
953 * @return WP_REST_Response|WP_Error Response object
954 */
955 public function get_intelligent_dashboard(WP_REST_Request $request) {
956 try {
957 $date_range = $request->get_param('date_range');
958
959 // Cache the intelligence computation (trend analysis + generators are
960 // expensive) so the AI-insights panel doesn't recompute every load.
961 $response = $this->cached_response(
962 'intelligent_dashboard',
963 function () use ($date_range) {
964 $intelligent_data = $this->analytics_manager->get_intelligent_dashboard_data($date_range);
965
966 return [
967 'success' => isset($intelligent_data['success']) ? $intelligent_data['success'] : false,
968 'data' => $intelligent_data['data'] ?? null,
969 'message' => $intelligent_data['message'] ?? 'Intelligent dashboard data retrieved',
970 'timestamp' => current_time('mysql'),
971 ];
972 },
973 ['date_range' => $date_range]
974 );
975
976 // Always return 200 for successful API calls, even when no data available.
977 return new WP_REST_Response($response, 200);
978
979 } catch (\Exception $e) {
980 return new WP_Error(
981 'intelligent_dashboard_error',
982 'Failed to retrieve intelligent dashboard data: ' . $e->getMessage(),
983 ['status' => 500]
984 );
985 }
986 }
987
988 /**
989 * Get intelligent SEO opportunities with prioritization
990 *
991 * @param WP_REST_Request $request Request object
992 * @return WP_REST_Response|WP_Error Response object
993 */
994 public function get_intelligent_opportunities(WP_REST_Request $request) {
995 try {
996 $date_range = $request->get_param('date_range');
997
998 // Cache the opportunity detection so it doesn't recompute every load.
999 $response = $this->cached_response(
1000 'intelligent_opportunities',
1001 function () use ($date_range) {
1002 $intelligent_opportunities = $this->analytics_manager->get_intelligent_seo_opportunities($date_range);
1003
1004 return [
1005 'success' => isset($intelligent_opportunities['success']) ? $intelligent_opportunities['success'] : false,
1006 'data' => $intelligent_opportunities['data'] ?? null,
1007 'message' => $intelligent_opportunities['message'] ?? 'Intelligent opportunities retrieved',
1008 'timestamp' => current_time('mysql'),
1009 ];
1010 },
1011 ['date_range' => $date_range]
1012 );
1013
1014 // Always return 200 for successful API calls, even when no data available.
1015 return new WP_REST_Response($response, 200);
1016
1017 } catch (\Exception $e) {
1018 return new WP_Error(
1019 'intelligent_opportunities_error',
1020 'Failed to retrieve intelligent opportunities: ' . $e->getMessage(),
1021 ['status' => 500]
1022 );
1023 }
1024 }
1025
1026 /**
1027 * Get SEO insights
1028 *
1029 * @param WP_REST_Request $request Request object
1030 * @return WP_REST_Response|WP_Error Response object
1031 */
1032 public function get_seo_insights(WP_REST_Request $request) {
1033 try {
1034 $date_range = $request->get_param('date_range');
1035
1036 // Endpoint-level cache for consistency with the other two intelligence
1037 // calls (the manager also caches insights internally).
1038 $response = $this->cached_response(
1039 'seo_insights',
1040 function () use ($date_range) {
1041 $insights = $this->analytics_manager->get_seo_insights($date_range);
1042
1043 return [
1044 'success' => isset($insights['success']) ? $insights['success'] : false,
1045 'data' => $insights['data'] ?? null,
1046 'cached' => $insights['cached'] ?? false,
1047 'message' => $insights['message'] ?? 'SEO insights retrieved',
1048 'timestamp' => current_time('mysql'),
1049 ];
1050 },
1051 ['date_range' => $date_range]
1052 );
1053
1054 // Always return 200 for successful API calls, even when no data available.
1055 return new WP_REST_Response($response, 200);
1056
1057 } catch (\Exception $e) {
1058 return new WP_Error(
1059 'seo_insights_error',
1060 'Failed to retrieve SEO insights: ' . $e->getMessage(),
1061 ['status' => 500]
1062 );
1063 }
1064 }
1065 }
1066