Files
newznab-tmux/app/Services/UserStatsService.php
T
2026-07-20 13:07:56 +02:00

301 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use App\Models\UserActivityStat;
use App\Models\UserDownload;
use App\Models\UserRequest;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class UserStatsService
{
/**
* Get user statistics by role
*
* @return array<string, mixed>
*/
public function getUsersByRole(): array
{
$usersByRole = User::query()
->join('roles', 'users.roles_id', '=', 'roles.id')
->select('roles.name as role_name', DB::raw('COUNT(users.id) as count'))
->whereNull('users.deleted_at')
->groupBy('roles.id', 'roles.name')
->get();
return $usersByRole->map(function ($item) {
return [
'role' => $item->role_name,
'count' => $item->count,
];
})->toArray();
}
/**
* Get downloads per day for the last N days (inclusive of today).
*
* Closed hours come from `user_activity_stats_hourly`; the current hour
* comes from the live `user_downloads` table.
*
* @return list<array<string, int|string>>
*/
public function getDownloadsPerDay(int $days = 7): array
{
return $this->buildPerDaySeries(
$days,
'downloads_count',
static fn (Carbon $from): int => UserDownload::query()
->where('timestamp', '>=', $from)
->count()
);
}
/**
* Get downloads per hour for the last N hours
* Uses aggregated hourly stats from user_activity_stats_hourly table
*
* @return list<array<string, int|string|null>>
*/
public function getDownloadsPerHour(int $hours = 168): array
{
$result = UserActivityStat::getDownloadsPerHour($hours);
if ($result !== []) {
$result[count($result) - 1]['count'] = UserDownload::query()
->where('timestamp', '>=', Carbon::now()->startOfHour())
->count();
}
return $result;
}
/**
* Get downloads per minute for the last N minutes
*
* @return array<string, mixed>
*/
public function getDownloadsPerMinute(int $minutes = 60): array
{
$startTime = Carbon::now()->subMinutes($minutes);
$minuteExpression = $this->minuteBucketExpression();
$downloads = UserDownload::query()
->selectRaw($minuteExpression.' as minute, COUNT(*) as count')
->where('timestamp', '>=', $startTime)
->groupByRaw($minuteExpression)
->orderBy('minute', 'asc')
->get()
->keyBy('minute');
// Fill in missing minutes with zero counts (O(n) instead of firstWhere O(n^2))
$result = [];
for ($i = $minutes - 1; $i >= 0; $i--) {
$time = Carbon::now()->subMinutes($i);
$minuteKey = $time->format('Y-m-d H:i:00');
$found = $downloads->get($minuteKey);
$result[] = [
'time' => $time->format('H:i'),
'count' => $found ? (int) $found->count : 0,
];
}
return $result;
}
/**
* Get API hits per day for the last N days (inclusive of today).
*
* Mirrors {@see self::getDownloadsPerDay()} using combined authenticated
* API v1/v2 and RSS requests from `user_requests`.
*
* @return list<array<string, int|string>>
*/
public function getApiHitsPerDay(int $days = 7): array
{
return $this->buildPerDaySeries(
$days,
'api_hits_count',
static fn (Carbon $from): int => UserRequest::query()
->where('timestamp', '>=', $from)
->count()
);
}
/**
* Get API hits per hour for the last N hours
* Uses aggregated hourly stats from user_activity_stats_hourly table
*
* @return list<array<string, int|string|null>>
*/
public function getApiHitsPerHour(int $hours = 168): array
{
$result = UserActivityStat::getApiHitsPerHour($hours);
if ($result !== []) {
$result[count($result) - 1]['count'] = UserRequest::query()
->where('timestamp', '>=', Carbon::now()->startOfHour())
->count();
}
return $result;
}
/**
* Get API hits per minute for the last N minutes
*
* @return array<string, mixed>
*/
public function getApiHitsPerMinute(int $minutes = 60): array
{
$startTime = Carbon::now()->subMinutes($minutes);
$minuteExpression = $this->minuteBucketExpression();
// Track actual API requests from user_requests table
$apiHits = UserRequest::query()
->selectRaw($minuteExpression.' as minute, COUNT(*) as count')
->where('timestamp', '>=', $startTime)
->groupByRaw($minuteExpression)
->orderBy('minute', 'asc')
->get()
->keyBy('minute');
// Fill in missing minutes with zero counts (O(n) instead of firstWhere O(n^2))
$result = [];
for ($i = $minutes - 1; $i >= 0; $i--) {
$time = Carbon::now()->subMinutes($i);
$minuteKey = $time->format('Y-m-d H:i:00');
$found = $apiHits->get($minuteKey);
$result[] = [
'time' => $time->format('H:i'),
'count' => $found ? (int) $found->count : 0,
];
}
return $result;
}
/**
* Get summary statistics for the headline tiles.
*
* Window: "today" and "(7d) = last 7 calendar days inclusive of today".
*
* Closed hours come from `user_activity_stats_hourly`; the current
* in-progress hour comes from live `user_downloads` / `user_requests`.
*
* @return array{total_users: int, downloads_today: int, downloads_week: int, api_hits_today: int, api_hits_week: int}
*/
public function getSummaryStats(): array
{
$today = Carbon::now()->startOfDay();
$weekStart = Carbon::now()->subDays(6)->startOfDay();
$currentHourStart = Carbon::now()->startOfHour();
$weekClosed = DB::table('user_activity_stats_hourly')
->where('stat_hour', '>=', $weekStart->format('Y-m-d H:00:00'))
->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00'))
->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits')
->first();
// Today: closed hours from hourly aggregate.
$todayClosed = DB::table('user_activity_stats_hourly')
->where('stat_hour', '>=', $today->format('Y-m-d H:00:00'))
->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00'))
->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits')
->first();
// Current in-progress hour from live tables.
$downloadsCurrentHour = UserDownload::query()
->where('timestamp', '>=', $currentHourStart)
->count();
$apiHitsCurrentHour = UserRequest::query()
->where('timestamp', '>=', $currentHourStart)
->count();
$downloadsToday = (int) ($todayClosed->downloads ?? 0) + $downloadsCurrentHour;
$apiHitsToday = (int) ($todayClosed->api_hits ?? 0) + $apiHitsCurrentHour;
return [
'total_users' => User::whereNull('deleted_at')->count(),
'downloads_today' => $downloadsToday,
'downloads_week' => (int) ($weekClosed->downloads ?? 0) + $downloadsCurrentHour,
'api_hits_today' => $apiHitsToday,
'api_hits_week' => (int) ($weekClosed->api_hits ?? 0) + $apiHitsCurrentHour,
];
}
/**
* Get top downloaders
*
* @return array{total_users: int<0, max>, downloads_today: int<0, max>, downloads_week: float|int, api_hits_today: int<0, max>, api_hits_week: float|int}
*/
public function getTopDownloaders(int $limit = 5): array
{
$weekAgo = Carbon::now()->subDays(7);
return UserDownload::query()
->join('users', 'user_downloads.users_id', '=', 'users.id')
->select('users.username', DB::raw('COUNT(*) as download_count'))
->where('user_downloads.timestamp', '>=', $weekAgo)
->groupBy('users.id', 'users.username')
->orderByDesc('download_count')
->limit($limit)
->get()
->toArray();
}
/**
* Build a "last N days inclusive of today" series from hourly aggregates
* plus the current in-progress hour from a caller-supplied live counter.
*
* @param callable(Carbon): int $liveCurrentHourCounter
* @return list<array{date: string, count: int}>
*/
private function buildPerDaySeries(int $days, string $statColumn, callable $liveCurrentHourCounter): array
{
$today = Carbon::now()->startOfDay();
$startDate = $today->copy()->subDays($days - 1);
$currentHourStart = Carbon::now()->startOfHour();
$hourly = DB::table('user_activity_stats_hourly')
->select('stat_hour', $statColumn)
->where('stat_hour', '>=', $startDate->format('Y-m-d H:00:00'))
->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00'))
->orderBy('stat_hour')
->get();
$dailyCounts = [];
foreach ($hourly as $stat) {
$date = Carbon::parse((string) $stat->stat_hour)->format('Y-m-d');
$dailyCounts[$date] = ($dailyCounts[$date] ?? 0) + (int) $stat->{$statColumn};
}
$result = [];
$cursor = $startDate->copy();
while ($cursor->lte($today)) {
$date = $cursor->format('Y-m-d');
$result[] = [
'date' => $cursor->format('M d'),
'count' => $dailyCounts[$date] ?? 0,
];
$cursor->addDay();
}
if ($result !== []) {
$result[count($result) - 1]['count'] += (int) $liveCurrentHourCounter($currentHourStart);
}
return $result;
}
private function minuteBucketExpression(): string
{
return DB::connection()->getDriverName() === 'sqlite'
? "strftime('%Y-%m-%d %H:%M:00', timestamp)"
: 'DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")';
}
}