PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.2.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.2.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-usage-analytics-endpoint.php

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

1,233 lines 44.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Usage Analytics REST API Endpoint
4 *
5 * Handles REST API endpoints for usage analytics data including AI usage, costs, and performance metrics
6 *
7 * @package ThinkRank\API
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\API;
14
15 use ThinkRank\Core\Database;
16 use ThinkRank\API\Traits\API_Cache;
17 use WP_REST_Request;
18 use WP_REST_Response;
19 use WP_Error;
20
21 // Prevent direct access
22 if (!defined('ABSPATH')) {
23 exit;
24 }
25
26 // Load API Cache trait
27 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-api-cache.php';
28
29 /**
30 * Usage Analytics Endpoint Class
31 *
32 * Provides REST API endpoints for:
33 * - /wp-json/thinkrank/v1/analytics/overview
34 * - /wp-json/thinkrank/v1/analytics/usage
35 * - /wp-json/thinkrank/v1/analytics/costs
36 *
37 * @since 1.0.0
38 */
39 class Usage_Analytics_Endpoint {
40
41 use API_Cache;
42
43 /**
44 * Database instance
45 *
46 * @var Database
47 */
48 private Database $database;
49
50 /**
51 * OpenAI pricing per 1M tokens (USD)
52 */
53 private const OPENAI_PRICING = [
54 // GPT-5 models (official pricing from OpenAI)
55 'gpt-5' => [
56 'input' => 1.25,
57 'output' => 10.00
58 ],
59 'gpt-5-mini' => [
60 'input' => 0.25,
61 'output' => 2.00
62 ],
63 'gpt-5-nano' => [
64 'input' => 0.05,
65 'output' => 0.40
66 ],
67 // GPT-4 models
68 'gpt-4.1' => [
69 'input' => 2.50,
70 'output' => 10.00
71 ],
72 'gpt-4o' => [
73 'input' => 2.50,
74 'output' => 10.00
75 ],
76 'gpt-4-turbo' => [
77 'input' => 10.00,
78 'output' => 30.00
79 ],
80 // O3 models
81 'o3-mini' => [
82 'input' => 1.25,
83 'output' => 5.00
84 ],
85 // Mini models
86 'gpt-4o-mini' => [
87 'input' => 0.15,
88 'output' => 0.60
89 ]
90 ];
91
92 /**
93 * Claude pricing per 1M tokens (USD)
94 * Model IDs sourced from https://docs.anthropic.com/en/docs/about-claude/models
95 */
96 private const CLAUDE_PRICING = [
97 // Current models (recommended)
98 'claude-opus-5' => [
99 'input' => 5.00,
100 'output' => 25.00
101 ],
102 'claude-opus-4-8' => [
103 'input' => 5.00,
104 'output' => 25.00
105 ],
106 'claude-sonnet-5' => [
107 'input' => 3.00,
108 'output' => 15.00
109 ],
110 'claude-haiku-4-5' => [
111 'input' => 1.00,
112 'output' => 5.00
113 ],
114 // Claude 4.x models
115 'claude-opus-4-6' => [
116 'input' => 5.00,
117 'output' => 25.00
118 ],
119 'claude-sonnet-4-6' => [
120 'input' => 3.00,
121 'output' => 15.00
122 ],
123 // Claude 4.5 models
124 'claude-haiku-4-5-20251001' => [
125 'input' => 1.00,
126 'output' => 5.00
127 ],
128 // Claude 3.5 models (legacy)
129 'claude-3-5-sonnet-20241022' => [
130 'input' => 3.00,
131 'output' => 15.00
132 ],
133 'claude-3-5-haiku-20241022' => [
134 'input' => 0.80,
135 'output' => 4.00
136 ],
137 'claude-3-opus-20240229' => [
138 'input' => 15.00,
139 'output' => 75.00
140 ]
141 ];
142
143 /**
144 * Gemini pricing per 1M tokens (USD)
145 */
146 private const GEMINI_PRICING = [
147 // Gemini 3.x models (tiered models use the base <=200k-token rate)
148 'gemini-3.1-pro' => [
149 'input' => 2.00,
150 'output' => 12.00
151 ],
152 // The id the UI offers; 3.1 Pro ships only under -preview.
153 'gemini-3.1-pro-preview' => [
154 'input' => 2.00,
155 'output' => 12.00
156 ],
157 'gemini-3.5-flash' => [
158 'input' => 1.50,
159 'output' => 9.00
160 ],
161 'gemini-3.1-flash-lite' => [
162 'input' => 0.25,
163 'output' => 1.50
164 ],
165 // Gemini 2.5 models
166 'gemini-2.5-flash' => [
167 'input' => 0.30,
168 'output' => 2.50
169 ],
170 'gemini-2.5-flash-lite' => [
171 'input' => 0.10,
172 'output' => 0.40
173 ],
174 'gemini-2.5-pro' => [
175 'input' => 1.25,
176 'output' => 10.00
177 ],
178 // Gemini 2.0 models
179 'gemini-2.0-flash' => [
180 'input' => 0.10,
181 'output' => 0.40
182 ],
183 // Gemini 1.5 models
184 'gemini-1.5-flash' => [
185 'input' => 0.075,
186 'output' => 0.30
187 ],
188 'gemini-1.5-pro' => [
189 'input' => 1.25,
190 'output' => 5.00
191 ]
192 ];
193
194 /**
195 * OpenRouter pricing per 1M tokens (USD)
196 *
197 * OpenRouter passes through each upstream model's pricing; these are
198 * representative rates for the curated model list used for cost estimates.
199 */
200 private const OPENROUTER_PRICING = [
201 'openai/gpt-4o-mini' => [
202 'input' => 0.15,
203 'output' => 0.60
204 ],
205 'anthropic/claude-sonnet-5' => [
206 'input' => 3.00,
207 'output' => 15.00
208 ],
209 'google/gemini-3.5-flash' => [
210 'input' => 1.50,
211 'output' => 9.00
212 ],
213 // Retired upstream, kept so historical usage rows still price correctly.
214 'anthropic/claude-3.5-sonnet' => [
215 'input' => 3.00,
216 'output' => 15.00
217 ],
218 'google/gemini-2.0-flash-001' => [
219 'input' => 0.10,
220 'output' => 0.40
221 ],
222 'meta-llama/llama-3.3-70b-instruct' => [
223 'input' => 0.12,
224 'output' => 0.30
225 ],
226 'deepseek/deepseek-chat' => [
227 'input' => 0.14,
228 'output' => 0.28
229 ]
230 ];
231
232 /**
233 * Time saved estimates per action (minutes)
234 */
235 private const TIME_SAVED_ESTIMATES = [
236 'seo_metadata' => 20,
237 'content_analysis' => 15,
238 'content_brief' => 45,
239 'seo_score' => 10
240 ];
241
242 /**
243 * Constructor
244 */
245 public function __construct() {
246 $this->database = new Database();
247
248 // Configure caching for analytics endpoints
249 $this->set_cache_prefix('thinkrank_analytics_');
250 $this->set_cache_duration(600); // 10 minutes for analytics data
251
252 // Set up cache invalidation hooks
253 $this->setup_cache_invalidation();
254 }
255
256 /**
257 * Register REST API routes
258 *
259 * @return void
260 */
261 public function register_routes(): void {
262 // Overview metrics endpoint
263 register_rest_route('thinkrank/v1', '/analytics/overview', [
264 'methods' => 'GET',
265 'callback' => [$this, 'get_overview_metrics'],
266 'permission_callback' => [$this, 'check_permissions'],
267 'args' => [
268 'period' => [
269 'default' => '30d',
270 'type' => 'string',
271 'enum' => ['7d', '30d', '90d', 'all'],
272 'sanitize_callback' => 'sanitize_key'
273 ],
274 'user_id' => [
275 'default' => 0,
276 'type' => 'integer',
277 'sanitize_callback' => 'absint'
278 ]
279 ]
280 ]);
281
282 // Usage breakdown endpoint
283 register_rest_route('thinkrank/v1', '/analytics/usage', [
284 'methods' => 'GET',
285 'callback' => [$this, 'get_usage_breakdown'],
286 'permission_callback' => [$this, 'check_permissions'],
287 'args' => [
288 'period' => [
289 'default' => '30d',
290 'type' => 'string',
291 'enum' => ['7d', '30d', '90d', 'all'],
292 'sanitize_callback' => 'sanitize_key'
293 ],
294 'group_by' => [
295 'default' => 'day',
296 'type' => 'string',
297 'enum' => ['day', 'week', 'month'],
298 'sanitize_callback' => 'sanitize_key'
299 ],
300 'user_id' => [
301 'default' => 0,
302 'type' => 'integer',
303 'sanitize_callback' => 'absint'
304 ]
305 ]
306 ]);
307
308 // Cost analysis endpoint
309 register_rest_route('thinkrank/v1', '/analytics/costs', [
310 'methods' => 'GET',
311 'callback' => [$this, 'get_cost_analysis'],
312 'permission_callback' => [$this, 'check_permissions'],
313 'args' => [
314 'period' => [
315 'default' => '30d',
316 'type' => 'string',
317 'enum' => ['7d', '30d', '90d', 'all'],
318 'sanitize_callback' => 'sanitize_key'
319 ],
320 'provider' => [
321 'default' => 'all',
322 'type' => 'string',
323 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter'],
324 'sanitize_callback' => 'sanitize_key'
325 ],
326 'user_id' => [
327 'default' => 0,
328 'type' => 'integer',
329 'sanitize_callback' => 'absint'
330 ]
331 ]
332 ]);
333 }
334
335 /**
336 * Get overview metrics
337 *
338 * @param WP_REST_Request $request Request object
339 * @return WP_REST_Response|WP_Error Response object
340 */
341 public function get_overview_metrics(WP_REST_Request $request) {
342 $period = $request->get_param('period');
343 $user_id = $request->get_param('user_id') ?: get_current_user_id();
344
345 try {
346 // Use cached response wrapper for performance
347 $response_data = $this->cached_response(
348 'overview_metrics',
349 function() use ($period, $user_id) {
350 // Get date range for queries
351 $date_condition = $this->get_date_condition($period);
352
353 // Get AI usage metrics
354 $ai_metrics = $this->get_ai_usage_metrics($user_id, $date_condition);
355
356 // Get SEO metrics
357 $seo_metrics = $this->get_seo_metrics($user_id, $date_condition);
358
359 // Get content brief metrics
360 $brief_metrics = $this->get_content_brief_metrics($user_id, $date_condition);
361
362 // Calculate costs
363 $cost_data = $this->calculate_costs($ai_metrics['usage_data'] ?? []);
364
365 // Calculate time saved
366 $time_saved = $this->calculate_time_saved($ai_metrics['feature_breakdown'] ?? []);
367
368 return [
369 'success' => true,
370 'data' => [
371 'content_optimized' => $seo_metrics['content_optimized'],
372 'content_optimized_change' => $seo_metrics['content_optimized_change'],
373 'average_seo_score' => $seo_metrics['average_seo_score'],
374 'seo_score_change' => $seo_metrics['seo_score_change'],
375 'total_tokens' => $ai_metrics['total_tokens'],
376 'total_cost' => $cost_data['total'],
377 'cost_change' => $ai_metrics['cost_change'],
378 'time_saved' => $time_saved,
379 'time_saved_change' => $ai_metrics['time_saved_change'],
380 'ai_actions' => $ai_metrics['total_actions'],
381 'features_used_count' => $ai_metrics['features_used_count'],
382 'most_used_feature' => $ai_metrics['most_used_feature'],
383 'most_used_count' => $ai_metrics['most_used_count'],
384 'success_rate' => $ai_metrics['success_rate'],
385 'content_briefs' => $brief_metrics['total_briefs'],
386 'feature_breakdown' => $ai_metrics['feature_breakdown'],
387 'provider_breakdown' => $cost_data['by_provider']
388 ],
389 'period' => $period,
390 'generated_at' => current_time('c')
391 ];
392 },
393 ['period' => $period],
394 null, // Use default cache duration
395 $user_id
396 );
397
398 return new WP_REST_Response($response_data, 200);
399
400 } catch (\Exception $e) {
401 return new WP_Error(
402 'analytics_error',
403 'Failed to retrieve analytics data: ' . $e->getMessage(),
404 ['status' => 500]
405 );
406 }
407 }
408
409 /**
410 * Check permissions for analytics endpoints
411 *
412 * @param WP_REST_Request $request Request object
413 * @return bool|WP_Error Permission result
414 */
415 public function check_permissions(WP_REST_Request $request) {
416 // Check if user is logged in
417 if (!is_user_logged_in()) {
418 return new WP_Error(
419 'not_logged_in',
420 'You must be logged in to view analytics data.',
421 ['status' => 401]
422 );
423 }
424
425 // Check if user can edit posts (basic content management capability)
426 if (!current_user_can('edit_posts')) {
427 return new WP_Error(
428 'insufficient_permissions',
429 'You do not have permission to view analytics data.',
430 ['status' => 403]
431 );
432 }
433
434 // If requesting another user's data, check admin permissions
435 $requested_user_id = $request->get_param('user_id');
436 if ($requested_user_id && $requested_user_id !== get_current_user_id()) {
437 if (!current_user_can('manage_options')) {
438 return new WP_Error(
439 'insufficient_permissions',
440 'You do not have permission to view other users\' analytics data.',
441 ['status' => 403]
442 );
443 }
444 }
445
446 return true;
447 }
448
449 /**
450 * Set up cache invalidation hooks
451 *
452 * @since 1.0.0
453 * @return void
454 */
455 private function setup_cache_invalidation(): void {
456 // Invalidate analytics cache when AI usage is logged
457 add_action('thinkrank_ai_usage_logged', [$this, 'invalidate_analytics_cache']);
458
459 // Invalidate analytics cache when SEO scores are updated
460 add_action('thinkrank_seo_score_updated', [$this, 'invalidate_analytics_cache']);
461
462 // Invalidate analytics cache when content briefs are created
463 add_action('thinkrank_content_brief_created', [$this, 'invalidate_analytics_cache']);
464 }
465
466 /**
467 * Invalidate analytics cache
468 *
469 * @since 1.0.0
470 * @return void
471 */
472 public function invalidate_analytics_cache(): void {
473 // Clear all analytics cache entries
474 $this->invalidate_cache_pattern('thinkrank_analytics_*');
475 }
476
477 /**
478 * Get the cutoff datetime string for a period.
479 * Returns null for 'all' (no date restriction).
480 *
481 * @param string $period Period string
482 * @return string|null Cutoff datetime in MySQL format, or null for all time
483 */
484 private function get_date_cutoff(string $period): ?string {
485 switch ($period) {
486 case '7d':
487 $days = 7;
488 break;
489 case '30d':
490 $days = 30;
491 break;
492 case '90d':
493 $days = 90;
494 break;
495 case 'all':
496 $days = null;
497 break;
498 default:
499 $days = 30;
500 }
501 if ($days === null) {
502 return null;
503 }
504 return gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
505 }
506
507 /**
508 * Get date condition for SQL queries
509 *
510 * @deprecated Use get_date_cutoff() with parameterized queries instead.
511 * Kept for back-compat with get_previous_period_condition() parsing.
512 *
513 * @param string $period Period string
514 * @return string SQL date condition
515 */
516 private function get_date_condition(string $period): string {
517 switch ($period) {
518 case '7d':
519 return "AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
520 case '30d':
521 return "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)";
522 case '90d':
523 return "AND created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)";
524 case 'all':
525 return "";
526 default:
527 return "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)";
528 }
529 }
530
531 /**
532 * Calculate costs from usage data
533 *
534 * @param array $usage_data Usage data array
535 * @return array Cost breakdown
536 */
537 private function calculate_costs(array $usage_data): array {
538 $costs = [
539 'openai' => 0,
540 'claude' => 0,
541 'gemini' => 0,
542 'openrouter' => 0,
543 'total' => 0,
544 'by_provider' => []
545 ];
546
547 foreach ($usage_data as $usage) {
548 $tokens = (int) $usage['tokens_used'];
549 $provider = $usage['provider'];
550
551 // Estimate 70% input, 30% output tokens
552 $input_tokens = $tokens * 0.7;
553 $output_tokens = $tokens * 0.3;
554
555 $cost = 0;
556
557 // Use the robust pricing helper for consistent cost calculation
558 $pricing = $this->get_model_pricing($provider);
559 if ($pricing) {
560 $cost = ($input_tokens * $pricing['input'] / 1000000) +
561 ($output_tokens * $pricing['output'] / 1000000);
562 $costs[$provider] += $cost;
563 }
564 }
565
566 $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'];
567
568 // Format provider breakdown
569 $costs['by_provider'] = [
570 'openai' => [
571 'cost' => round($costs['openai'], 4),
572 'percentage' => $costs['total'] > 0 ? round(($costs['openai'] / $costs['total']) * 100, 1) : 0
573 ],
574 'claude' => [
575 'cost' => round($costs['claude'], 4),
576 'percentage' => $costs['total'] > 0 ? round(($costs['claude'] / $costs['total']) * 100, 1) : 0
577 ],
578 'gemini' => [
579 'cost' => round($costs['gemini'], 4),
580 'percentage' => $costs['total'] > 0 ? round(($costs['gemini'] / $costs['total']) * 100, 1) : 0
581 ],
582 'openrouter' => [
583 'cost' => round($costs['openrouter'], 4),
584 'percentage' => $costs['total'] > 0 ? round(($costs['openrouter'] / $costs['total']) * 100, 1) : 0
585 ]
586 ];
587
588 return $costs;
589 }
590
591 /**
592 * Calculate time saved from feature usage
593 *
594 * @param array $feature_breakdown Feature usage breakdown
595 * @return int Time saved in minutes
596 */
597 private function calculate_time_saved(array $feature_breakdown): int {
598 $total_time_saved = 0;
599
600 foreach ($feature_breakdown as $feature => $count) {
601 $time_per_action = self::TIME_SAVED_ESTIMATES[$feature] ?? 15; // Default 15 minutes
602 $total_time_saved += $count * $time_per_action;
603 }
604
605 return $total_time_saved;
606 }
607
608 /**
609 * Get AI usage metrics from database
610 *
611 * @param int $user_id User ID
612 * @param string $date_condition SQL date condition
613 * @return array AI usage metrics
614 */
615 private function get_ai_usage_metrics(int $user_id, string $date_condition): array {
616 global $wpdb;
617
618 // Get table name and escape it properly (table names cannot be parameterized)
619 $table_name = esc_sql($this->database->get_table('ai_usage'));
620
621 $cutoff = $this->get_date_cutoff($this->resolve_period_from_condition($date_condition));
622
623 if ($cutoff !== null) {
624 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
625 $usage_data = $wpdb->get_results(
626 $wpdb->prepare(
627 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
628 "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
629 $user_id,
630 $cutoff
631 ),
632 ARRAY_A
633 );
634 } else {
635 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
636 $usage_data = $wpdb->get_results(
637 $wpdb->prepare(
638 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
639 "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d",
640 $user_id
641 ),
642 ARRAY_A
643 );
644 }
645
646 if (empty($usage_data)) {
647 // Must return the same shape as the populated path below —
648 // get_overview_metrics() reads every key unconditionally, so a
649 // short array here surfaces as undefined-key warnings and null
650 // fields for any user with no AI usage yet (i.e. a fresh install).
651 // The values mirror what the loop below produces for zero rows.
652 return [
653 'total_actions' => 0,
654 'total_tokens' => 0,
655 'feature_breakdown' => [],
656 'features_used_count' => 0,
657 'most_used_feature' => '',
658 'most_used_count' => 0,
659 'success_rate' => 0,
660 'usage_data' => [],
661 'cost_change' => 0,
662 'time_saved_change' => 0
663 ];
664 }
665
666 // Calculate feature breakdown and new metrics
667 $feature_breakdown = [];
668 $total_actions = 0;
669 $total_tokens = 0;
670
671 foreach ($usage_data as $row) {
672 $action = $row['action'];
673 $tokens = (int) $row['tokens_used'];
674
675 if (!isset($feature_breakdown[$action])) {
676 $feature_breakdown[$action] = 0;
677 }
678 $feature_breakdown[$action]++;
679 $total_actions++;
680 $total_tokens += $tokens;
681 }
682
683 // Calculate new metrics
684 $features_used_count = count($feature_breakdown);
685
686 // Find most used feature
687 $most_used_feature = '';
688 $most_used_count = 0;
689 foreach ($feature_breakdown as $feature => $count) {
690 if ($count > $most_used_count) {
691 $most_used_feature = $feature;
692 $most_used_count = $count;
693 }
694 }
695
696 // Calculate success rate (assuming all logged actions are successful for now)
697 // In future, we could track failed attempts separately
698 $success_rate = $total_actions > 0 ? 100 : 0;
699
700 // Calculate changes from previous period
701 $previous_period_data = $this->get_previous_period_data($user_id, $date_condition);
702 $cost_change = $this->calculate_percentage_change(
703 $previous_period_data['total_cost'] ?? 0,
704 $this->calculate_total_cost($usage_data)
705 );
706 $time_saved_change = $this->calculate_percentage_change(
707 $previous_period_data['time_saved'] ?? 0,
708 $this->calculate_time_saved($feature_breakdown)
709 );
710
711 return [
712 'total_actions' => $total_actions,
713 'total_tokens' => $total_tokens,
714 'feature_breakdown' => $feature_breakdown,
715 'features_used_count' => $features_used_count,
716 'most_used_feature' => $most_used_feature,
717 'most_used_count' => $most_used_count,
718 'success_rate' => $success_rate,
719 'usage_data' => $usage_data,
720 'cost_change' => $cost_change,
721 'time_saved_change' => $time_saved_change
722 ];
723 }
724
725 /**
726 * Get SEO metrics from database
727 *
728 * @param int $user_id User ID
729 * @param string $date_condition SQL date condition
730 * @return array SEO metrics
731 */
732 private function get_seo_metrics(int $user_id, string $date_condition): array {
733 global $wpdb;
734
735 $table_name = esc_sql($this->database->get_table('seo_scores'));
736 $cutoff = $this->get_date_cutoff($this->resolve_period_from_condition($date_condition));
737
738 if ($cutoff !== null) {
739 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
740 $result = $wpdb->get_row(
741 $wpdb->prepare(
742 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
743 "SELECT COUNT(DISTINCT post_id) as content_optimized, AVG(overall_score) as average_score FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
744 $user_id,
745 $cutoff
746 ),
747 ARRAY_A
748 );
749 } else {
750 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
751 $result = $wpdb->get_row(
752 $wpdb->prepare(
753 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
754 "SELECT COUNT(DISTINCT post_id) as content_optimized, AVG(overall_score) as average_score FROM `{$table_name}` WHERE user_id = %d",
755 $user_id
756 ),
757 ARRAY_A
758 );
759 }
760
761 if (!$result || (int) $result['content_optimized'] === 0) {
762 return [
763 'content_optimized' => 0,
764 'average_seo_score' => 0,
765 'content_optimized_change' => 0,
766 'seo_score_change' => 0
767 ];
768 }
769
770 // Calculate changes from previous period
771 $previous_seo_data = $this->get_previous_seo_data($user_id, $date_condition);
772 $content_optimized_change = $this->calculate_percentage_change(
773 $previous_seo_data['content_optimized'] ?? 0,
774 (int) $result['content_optimized']
775 );
776 $seo_score_change = $this->calculate_percentage_change(
777 $previous_seo_data['average_seo_score'] ?? 0,
778 round((float) $result['average_score'], 1)
779 );
780
781 return [
782 'content_optimized' => (int) $result['content_optimized'],
783 'average_seo_score' => round((float) $result['average_score'], 1),
784 'content_optimized_change' => $content_optimized_change,
785 'seo_score_change' => $seo_score_change
786 ];
787 }
788
789 /**
790 * Get content brief metrics from database
791 *
792 * @param int $user_id User ID
793 * @param string $date_condition SQL date condition
794 * @return array Content brief metrics
795 */
796 private function get_content_brief_metrics(int $user_id, string $date_condition): array {
797 global $wpdb;
798
799 $table_name = esc_sql($this->database->get_table('content_briefs'));
800 $cutoff = $this->get_date_cutoff($this->resolve_period_from_condition($date_condition));
801
802 if ($cutoff !== null) {
803 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
804 $result = $wpdb->get_var(
805 $wpdb->prepare(
806 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
807 "SELECT COUNT(*) as total_briefs FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
808 $user_id,
809 $cutoff
810 )
811 );
812 } else {
813 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
814 $result = $wpdb->get_var(
815 $wpdb->prepare(
816 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
817 "SELECT COUNT(*) as total_briefs FROM `{$table_name}` WHERE user_id = %d",
818 $user_id
819 )
820 );
821 }
822
823 return [
824 'total_briefs' => (int) $result ?: 0
825 ];
826 }
827
828 /**
829 * Get detailed usage breakdown
830 *
831 * @param WP_REST_Request $request Request object
832 * @return WP_REST_Response|WP_Error Response object
833 */
834 public function get_usage_breakdown(WP_REST_Request $request) {
835 try {
836 $user_id = get_current_user_id();
837 $period = $request->get_param('period') ?? '30d';
838 // `(int)` binds tighter than `??`, so `(int) null` is 0 and the
839 // `?? 20` fallback was unreachable — per_page silently defaulted to
840 // the max(10, 0) floor of 10 rather than the 20 it advertises, and
841 // page to max(1, 0) = 1 by luck rather than intent (#394).
842 $page = max(1, (int) ($request->get_param('page') ?? 1));
843 $per_page = min(100, max(10, (int) ($request->get_param('per_page') ?? 20)));
844 $offset = ($page - 1) * $per_page;
845
846 // Get date range for queries
847 $date_condition = $this->get_date_condition($period);
848
849 // Get detailed usage breakdown
850 $usage_data = $this->get_detailed_usage_breakdown($user_id, $date_condition, $per_page, $offset);
851 $total_records = $this->get_usage_breakdown_count($user_id, $date_condition);
852
853 return new WP_REST_Response([
854 'success' => true,
855 'data' => [
856 'usage_records' => $usage_data,
857 'pagination' => [
858 'page' => $page,
859 'per_page' => $per_page,
860 'total_records' => $total_records,
861 'total_pages' => ceil($total_records / $per_page)
862 ],
863 'period' => $period
864 ]
865 ], 200);
866
867 } catch (\Exception $e) {
868 return new WP_Error(
869 'usage_breakdown_failed',
870 'Failed to get usage breakdown: ' . $e->getMessage(),
871 ['status' => 500]
872 );
873 }
874 }
875
876 /**
877 * Get cost analysis (placeholder for Phase 2)
878 *
879 * @param WP_REST_Request $request Request object
880 * @return WP_REST_Response|WP_Error Response object
881 */
882 public function get_cost_analysis(WP_REST_Request $request) {
883 // Return 200 with success:false so the frontend can render an
884 // "unavailable" state — apiFetch rejects on non-2xx, which would
885 // otherwise surface as a generic hard error.
886 return new WP_REST_Response([
887 'success' => false,
888 'data' => null,
889 'message' => 'Cost analysis is not yet implemented.'
890 ], 200);
891 }
892
893 /**
894 * Calculate total cost from usage data
895 *
896 * @param array $usage_data Usage data array
897 * @return float Total cost
898 */
899 private function calculate_total_cost(array $usage_data): float {
900 $cost_data = $this->calculate_costs($usage_data);
901 return $cost_data['total'];
902 }
903
904 /**
905 * Calculate percentage change between two values
906 *
907 * @param float $old_value Previous period value
908 * @param float $new_value Current period value
909 * @return float Percentage change
910 */
911 private function calculate_percentage_change(float $old_value, float $new_value): float {
912 if ((float) $old_value === 0.0) {
913 return $new_value > 0 ? 100 : 0;
914 }
915
916 return round((($new_value - $old_value) / $old_value) * 100, 1);
917 }
918
919 /**
920 * Get previous period data for comparison
921 *
922 * @param int $user_id User ID
923 * @param string $current_date_condition Current period date condition
924 * @return array Previous period data
925 */
926 private function get_previous_period_data(int $user_id, string $current_date_condition): array {
927 global $wpdb;
928
929 // Get table name and escape it properly (table names cannot be parameterized)
930 $table_name = esc_sql($this->database->get_table('ai_usage'));
931
932 // Extract the interval from current date condition to calculate previous period
933 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
934
935 // Prepare and execute query with proper parameter binding to prevent SQL injection
936 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
937 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
938 $usage_data = $wpdb->get_results(
939 $wpdb->prepare("
940 SELECT
941 provider,
942 action,
943 tokens_used
944 FROM `{$table_name}`
945 WHERE user_id = %d
946 {$previous_date_condition}
947 ", $user_id),
948 ARRAY_A
949 );
950 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
951
952 if (empty($usage_data)) {
953 return ['total_cost' => 0, 'time_saved' => 0];
954 }
955
956 // Calculate feature breakdown for time saved
957 $feature_breakdown = [];
958 foreach ($usage_data as $row) {
959 $action = $row['action'];
960 if (!isset($feature_breakdown[$action])) {
961 $feature_breakdown[$action] = 0;
962 }
963 $feature_breakdown[$action]++;
964 }
965
966 return [
967 'total_cost' => $this->calculate_total_cost($usage_data),
968 'time_saved' => $this->calculate_time_saved($feature_breakdown)
969 ];
970 }
971
972 /**
973 * Get detailed usage breakdown with pagination
974 *
975 * @param int $user_id User ID
976 * @param string $date_condition SQL date condition
977 * @param int $limit Number of records to return
978 * @param int $offset Offset for pagination
979 * @return array Detailed usage records
980 */
981 private function get_detailed_usage_breakdown(int $user_id, string $date_condition, int $limit, int $offset): array {
982 global $wpdb;
983
984 $table_name = esc_sql($this->database->get_table('ai_usage'));
985
986 $sql = "
987 SELECT
988 id,
989 provider,
990 action,
991 tokens_used,
992 post_id,
993 metadata,
994 created_at
995 FROM `{$table_name}`
996 WHERE user_id = %d
997 {$date_condition}
998 ORDER BY created_at DESC
999 LIMIT %d OFFSET %d
1000 ";
1001
1002 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time, table name and date condition are validated internally
1003 $usage_data = $wpdb->get_results(
1004 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1005 $wpdb->prepare($sql, $user_id, $limit, $offset),
1006 ARRAY_A
1007 );
1008
1009 // Process and enrich the data
1010 $processed_data = [];
1011 foreach ($usage_data as $record) {
1012 $metadata = !empty($record['metadata']) ? json_decode($record['metadata'], true) : [];
1013 $model = $metadata['actual_model'] ?? $this->get_default_model($record['provider']);
1014 $cost = $this->calculate_record_cost($record['provider'], (int) $record['tokens_used'], $model);
1015
1016 $processed_data[] = [
1017 'id' => (int) $record['id'],
1018 'provider' => $record['provider'],
1019 'model' => $model,
1020 'action' => $record['action'],
1021 'tokens_used' => (int) $record['tokens_used'],
1022 'estimated_cost' => $cost,
1023 'post_id' => $record['post_id'] ? (int) $record['post_id'] : null,
1024 'created_at' => $record['created_at'],
1025 'formatted_date' => wp_date('M j, Y g:i A', strtotime($record['created_at']))
1026 ];
1027 }
1028
1029 return $processed_data;
1030 }
1031
1032 /**
1033 * Get total count of usage records for pagination
1034 *
1035 * @param int $user_id User ID
1036 * @param string $date_condition SQL date condition
1037 * @return int Total record count
1038 */
1039 private function get_usage_breakdown_count(int $user_id, string $date_condition): int {
1040 global $wpdb;
1041
1042 $table_name = esc_sql($this->database->get_table('ai_usage'));
1043
1044 $sql = "
1045 SELECT COUNT(*)
1046 FROM `{$table_name}`
1047 WHERE user_id = %d
1048 {$date_condition}
1049 ";
1050
1051 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time, table name and date condition are validated internally
1052 $count = $wpdb->get_var(
1053 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1054 $wpdb->prepare($sql, $user_id)
1055 );
1056
1057 return (int) $count;
1058 }
1059
1060 /**
1061 * Calculate cost for a single record using the robust pricing helper
1062 *
1063 * @param string $provider AI provider
1064 * @param int $tokens_used Number of tokens used
1065 * @param string $model Optional specific model name
1066 * @return float Estimated cost
1067 */
1068 private function calculate_record_cost(string $provider, int $tokens_used, string $model = ''): float {
1069 // Get pricing using the robust helper method
1070 $pricing = $this->get_model_pricing($provider, $model);
1071
1072 if (!$pricing) {
1073 return 0.0;
1074 }
1075
1076 // Estimate 70% input, 30% output tokens
1077 $input_tokens = $tokens_used * 0.7;
1078 $output_tokens = $tokens_used * 0.3;
1079
1080 return (($input_tokens / 1000000) * $pricing['input']) +
1081 (($output_tokens / 1000000) * $pricing['output']);
1082 }
1083
1084 /**
1085 * Get pricing for any model with intelligent fallbacks
1086 *
1087 * @param string $provider AI provider
1088 * @param string $model Model name (optional)
1089 * @return array|null Pricing array with 'input' and 'output' keys, or null if not found
1090 */
1091 private function get_model_pricing(string $provider, string $model = ''): ?array {
1092 switch ($provider) {
1093 case 'openai':
1094 // Try specific model first, fallback to default
1095 if ($model && isset(self::OPENAI_PRICING[$model])) {
1096 return self::OPENAI_PRICING[$model];
1097 }
1098 return self::OPENAI_PRICING['gpt-4o'] ?? null;
1099
1100 case 'claude':
1101 // Try specific model first, fallback to recommended default
1102 if ($model && isset(self::CLAUDE_PRICING[$model])) {
1103 return self::CLAUDE_PRICING[$model];
1104 }
1105 return self::CLAUDE_PRICING['claude-sonnet-5'] ??
1106 self::CLAUDE_PRICING['claude-sonnet-4-6'] ?? null;
1107
1108 case 'gemini':
1109 // Try specific model first, fallback to default
1110 if ($model && isset(self::GEMINI_PRICING[$model])) {
1111 return self::GEMINI_PRICING[$model];
1112 }
1113 return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null;
1114
1115 case 'openrouter':
1116 // Try specific model first, fallback to default
1117 if ($model && isset(self::OPENROUTER_PRICING[$model])) {
1118 return self::OPENROUTER_PRICING[$model];
1119 }
1120 return self::OPENROUTER_PRICING['openai/gpt-4o-mini'] ?? null;
1121
1122 default:
1123 return null;
1124 }
1125 }
1126
1127 /**
1128 * Get default model for provider using proper aliases
1129 *
1130 * @param string $provider AI provider
1131 * @return string Default model name
1132 */
1133 private function get_default_model(string $provider): string {
1134 switch ($provider) {
1135 case 'openai':
1136 return \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL;
1137 case 'claude':
1138 return \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL;
1139 case 'gemini':
1140 return \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL;
1141 case 'openrouter':
1142 return \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL;
1143 default:
1144 return 'unknown';
1145 }
1146 }
1147
1148 /**
1149 * Get previous SEO data for comparison
1150 *
1151 * @param int $user_id User ID
1152 * @param string $current_date_condition Current period date condition
1153 * @return array Previous SEO data
1154 */
1155 private function get_previous_seo_data(int $user_id, string $current_date_condition): array {
1156 global $wpdb;
1157
1158 // Get table name and escape it properly (table names cannot be parameterized)
1159 $table_name = esc_sql($this->database->get_table('seo_scores'));
1160
1161 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1162
1163 // Prepare and execute query with proper parameter binding to prevent SQL injection
1164 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1165 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
1166 $result = $wpdb->get_row(
1167 $wpdb->prepare("
1168 SELECT
1169 COUNT(DISTINCT post_id) as content_optimized,
1170 AVG(overall_score) as average_score
1171 FROM `{$table_name}`
1172 WHERE user_id = %d
1173 {$previous_date_condition}
1174 ", $user_id),
1175 ARRAY_A
1176 );
1177 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1178
1179 if (!$result) {
1180 return ['content_optimized' => 0, 'average_seo_score' => 0];
1181 }
1182
1183 return [
1184 'content_optimized' => (int) $result['content_optimized'],
1185 'average_seo_score' => round((float) $result['average_score'], 1)
1186 ];
1187 }
1188
1189 /**
1190 * Resolve a period key from a legacy SQL date condition string.
1191 * Used internally so the new parameterized helpers can derive the period.
1192 *
1193 * @param string $condition Legacy date condition string
1194 * @return string Period key
1195 */
1196 private function resolve_period_from_condition(string $condition): string {
1197 if (strpos($condition, 'INTERVAL 7') !== false) { return '7d';
1198 }
1199 if (strpos($condition, 'INTERVAL 30') !== false) { return '30d';
1200 }
1201 if (strpos($condition, 'INTERVAL 90') !== false) { return '90d';
1202 }
1203 if (empty(trim($condition))) { return 'all';
1204 }
1205 return '30d';
1206 }
1207
1208 /**
1209 * Convert current period condition to previous period condition
1210 *
1211 * @param string $current_condition Current period SQL condition
1212 * @return string Previous period SQL condition
1213 */
1214 private function get_previous_period_condition(string $current_condition): string {
1215 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1216 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1217 $interval = (int) $matches[1];
1218 $unit = $matches[2];
1219
1220 // Calculate previous period: if current is last 30 days, previous is 30-60 days ago
1221 $start_interval = $interval * 2;
1222 $end_interval = $interval;
1223
1224 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1225 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1226 }
1227
1228 // Fallback for unknown conditions
1229 return "AND created_at >= DATE_SUB(NOW(), INTERVAL 60 DAY)
1230 AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)";
1231 }
1232 }
1233