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

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