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

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