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

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