Implement academy analytics, billing, and web stories updates
This commit is contained in:
258
app/Console/Commands/AcademyAnalyticsRollupCommand.php
Normal file
258
app/Console/Commands/AcademyAnalyticsRollupCommand.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AcademyContentMetricDaily;
|
||||
use App\Models\AcademyEvent;
|
||||
use App\Models\AcademyLike;
|
||||
use App\Models\AcademySave;
|
||||
use App\Models\AcademySearchLog;
|
||||
use App\Services\Academy\AcademyPopularityService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class AcademyAnalyticsRollupCommand extends Command
|
||||
{
|
||||
protected $signature = 'academy:analytics-rollup {--date=} {--from=} {--to=}';
|
||||
|
||||
protected $description = 'Aggregate Academy analytics raw events and interaction records into daily content metrics';
|
||||
|
||||
public function __construct(private readonly AcademyPopularityService $popularity)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
[$from, $to] = $this->resolveRange();
|
||||
|
||||
foreach (CarbonPeriod::create($from, $to) as $date) {
|
||||
$this->rollupDate(Carbon::parse($date));
|
||||
$this->line(sprintf('Rolled up Academy analytics for %s.', Carbon::parse($date)->toDateString()));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function rollupDate(Carbon $date): void
|
||||
{
|
||||
$start = $date->copy()->startOfDay();
|
||||
$end = $date->copy()->endOfDay();
|
||||
$metrics = [];
|
||||
$uniqueVisitors = [];
|
||||
$engagedDurations = [];
|
||||
|
||||
AcademyEvent::query()
|
||||
->whereBetween('occurred_at', [$start, $end])
|
||||
->orderBy('id')
|
||||
->chunkById(1000, function ($events) use (&$metrics, &$uniqueVisitors, &$engagedDurations): void {
|
||||
foreach ($events as $event) {
|
||||
if ($event->is_bot || $event->is_admin || $event->is_suspicious) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->metricKey((string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null);
|
||||
$this->ensureMetric($metrics, (string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null, $key);
|
||||
|
||||
$visitorKey = $event->user_id ? sprintf('user:%d', (int) $event->user_id) : trim((string) ($event->visitor_id ?? ''));
|
||||
if ($visitorKey !== '') {
|
||||
$uniqueVisitors[$key][$visitorKey] = true;
|
||||
}
|
||||
|
||||
$eventType = (string) $event->event_type;
|
||||
if (in_array($eventType, ['academy_page_view', 'academy_content_view', 'academy_lesson_view', 'academy_course_view', 'academy_prompt_pack_view', 'academy_challenge_view'], true)) {
|
||||
$metrics[$key]['views']++;
|
||||
if ($event->is_logged_in) {
|
||||
$metrics[$key]['user_views']++;
|
||||
} else {
|
||||
$metrics[$key]['guest_views']++;
|
||||
}
|
||||
if ($event->is_subscriber) {
|
||||
$metrics[$key]['subscriber_views']++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($eventType === 'academy_engaged_view') {
|
||||
$metrics[$key]['engaged_views']++;
|
||||
$engagedDurations[$key][] = max(0, (int) ($event->metadata['engaged_seconds'] ?? 15));
|
||||
}
|
||||
|
||||
if ($eventType === 'academy_scroll_50') {
|
||||
$metrics[$key]['scroll_50']++;
|
||||
}
|
||||
if ($eventType === 'academy_scroll_75') {
|
||||
$metrics[$key]['scroll_75']++;
|
||||
}
|
||||
if ($eventType === 'academy_scroll_100') {
|
||||
$metrics[$key]['scroll_100']++;
|
||||
}
|
||||
if ($eventType === 'academy_prompt_copy') {
|
||||
$metrics[$key]['prompt_copies']++;
|
||||
}
|
||||
if ($eventType === 'academy_prompt_negative_copy') {
|
||||
$metrics[$key]['negative_prompt_copies']++;
|
||||
}
|
||||
if (in_array($eventType, ['academy_lesson_started', 'academy_course_started', 'academy_challenge_started'], true)) {
|
||||
$metrics[$key]['starts']++;
|
||||
}
|
||||
if (in_array($eventType, ['academy_lesson_completed', 'academy_course_completed', 'academy_challenge_submitted'], true)) {
|
||||
$metrics[$key]['completions']++;
|
||||
}
|
||||
if ($eventType === 'academy_upgrade_click') {
|
||||
$metrics[$key]['upgrade_clicks']++;
|
||||
}
|
||||
if ($eventType === 'academy_premium_preview_view') {
|
||||
$metrics[$key]['premium_preview_views']++;
|
||||
}
|
||||
if ($eventType === 'academy_search_result_click') {
|
||||
$metrics[$key]['search_clicks']++;
|
||||
|
||||
$searchKey = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
|
||||
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $searchKey);
|
||||
$metrics[$searchKey]['search_clicks']++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
foreach (AcademyLike::query()->whereBetween('created_at', [$start, $end])->get() as $like) {
|
||||
$key = $this->metricKey((string) $like->content_type, (int) $like->content_id);
|
||||
$this->ensureMetric($metrics, (string) $like->content_type, (int) $like->content_id, $key);
|
||||
$metrics[$key]['likes']++;
|
||||
}
|
||||
|
||||
foreach (AcademySave::query()->whereBetween('created_at', [$start, $end])->get() as $save) {
|
||||
$key = $this->metricKey((string) $save->content_type, (int) $save->content_id);
|
||||
$this->ensureMetric($metrics, (string) $save->content_type, (int) $save->content_id, $key);
|
||||
$metrics[$key]['saves']++;
|
||||
}
|
||||
|
||||
foreach (AcademySearchLog::query()->whereBetween('created_at', [$start, $end])->get() as $searchLog) {
|
||||
$key = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
|
||||
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $key);
|
||||
$metrics[$key]['search_impressions']++;
|
||||
if ((int) $searchLog->results_count === 0) {
|
||||
$metrics[$key]['bounce_count']++;
|
||||
}
|
||||
|
||||
$visitorKey = $searchLog->user_id ? sprintf('user:%d', (int) $searchLog->user_id) : trim((string) ($searchLog->visitor_id ?? ''));
|
||||
if ($visitorKey !== '') {
|
||||
$uniqueVisitors[$key][$visitorKey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($metrics as $key => $metric) {
|
||||
$metric['unique_visitors'] = isset($uniqueVisitors[$key]) ? count($uniqueVisitors[$key]) : 0;
|
||||
$metric['avg_engaged_seconds'] = isset($engagedDurations[$key]) && $engagedDurations[$key] !== []
|
||||
? (int) round(array_sum($engagedDurations[$key]) / count($engagedDurations[$key]))
|
||||
: null;
|
||||
$metric['bounce_count'] = max((int) ($metric['bounce_count'] ?? 0), max(0, (int) $metric['views'] - (int) $metric['engaged_views']));
|
||||
$metric['popularity_score'] = $this->popularity->calculatePopularityScore($metric);
|
||||
$metric['conversion_score'] = $this->popularity->calculateConversionScore($metric);
|
||||
|
||||
AcademyContentMetricDaily::query()->upsert([
|
||||
array_merge($metric, [
|
||||
'date' => $date->copy()->startOfDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]),
|
||||
], ['date', 'content_type', 'content_id'], [
|
||||
'views',
|
||||
'unique_visitors',
|
||||
'guest_views',
|
||||
'user_views',
|
||||
'subscriber_views',
|
||||
'engaged_views',
|
||||
'scroll_50',
|
||||
'scroll_75',
|
||||
'scroll_100',
|
||||
'likes',
|
||||
'saves',
|
||||
'prompt_copies',
|
||||
'negative_prompt_copies',
|
||||
'starts',
|
||||
'completions',
|
||||
'upgrade_clicks',
|
||||
'premium_preview_views',
|
||||
'search_impressions',
|
||||
'search_clicks',
|
||||
'bounce_count',
|
||||
'avg_engaged_seconds',
|
||||
'popularity_score',
|
||||
'conversion_score',
|
||||
'updated_at',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $metrics
|
||||
*/
|
||||
private function ensureMetric(array &$metrics, string $contentType, ?int $contentId, string $key): void
|
||||
{
|
||||
if (isset($metrics[$key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metrics[$key] = [
|
||||
'content_type' => $contentType,
|
||||
'content_id' => $contentId,
|
||||
'views' => 0,
|
||||
'unique_visitors' => 0,
|
||||
'guest_views' => 0,
|
||||
'user_views' => 0,
|
||||
'subscriber_views' => 0,
|
||||
'engaged_views' => 0,
|
||||
'scroll_50' => 0,
|
||||
'scroll_75' => 0,
|
||||
'scroll_100' => 0,
|
||||
'likes' => 0,
|
||||
'saves' => 0,
|
||||
'prompt_copies' => 0,
|
||||
'negative_prompt_copies' => 0,
|
||||
'starts' => 0,
|
||||
'completions' => 0,
|
||||
'upgrade_clicks' => 0,
|
||||
'premium_preview_views' => 0,
|
||||
'search_impressions' => 0,
|
||||
'search_clicks' => 0,
|
||||
'bounce_count' => 0,
|
||||
'avg_engaged_seconds' => null,
|
||||
'popularity_score' => 0,
|
||||
'conversion_score' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function metricKey(string $contentType, ?int $contentId): string
|
||||
{
|
||||
return sprintf('%s:%s', $contentType, $contentId ?? 'none');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon, 1: Carbon}
|
||||
*/
|
||||
private function resolveRange(): array
|
||||
{
|
||||
$date = $this->option('date');
|
||||
$from = $this->option('from');
|
||||
$to = $this->option('to');
|
||||
|
||||
if (is_string($date) && trim($date) !== '') {
|
||||
$resolved = Carbon::parse($date)->startOfDay();
|
||||
|
||||
return [$resolved, $resolved->copy()];
|
||||
}
|
||||
|
||||
$resolvedFrom = is_string($from) && trim($from) !== ''
|
||||
? Carbon::parse($from)->startOfDay()
|
||||
: now()->subDay()->startOfDay();
|
||||
$resolvedTo = is_string($to) && trim($to) !== ''
|
||||
? Carbon::parse($to)->startOfDay()
|
||||
: $resolvedFrom->copy();
|
||||
|
||||
return [$resolvedFrom, $resolvedTo];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user