PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.30.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.30.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 1.30.0, at includes/api/class-usage-analytics-endpoint.php

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