feat: log section

This commit is contained in:
2026-08-19 12:10:13 +03:30
parent 315b3170c8
commit 2123adef2b
5 changed files with 370 additions and 4 deletions
+156
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\AuthLog;
use App\Models\OldPurchase;
use App\Models\PackageName;
use App\Models\Product;
@@ -11,6 +12,7 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class AdminController extends Controller
{
@@ -91,6 +93,160 @@ public function purchases(Request $request)
return $purchases;
}
/**
* Validate and normalize the shared auth-log filters into a
* [$data, $from, $to] triple. Dates are Y-m-d and times H:i(:s) in the app
* timezone; a date without a time covers the whole day (mirrors purchases).
*/
private function authLogFilters(Request $request): array
{
$data = $request->validate([
'per_page' => 'integer',
'event' => ['nullable', Rule::in(AuthLog::EVENTS)],
'mobile' => 'string|nullable',
'email' => 'string|nullable',
'package_name' => 'string|nullable',
'success' => 'boolean|nullable',
'sms_only' => 'boolean|nullable',
'date_from' => 'date_format:Y-m-d|nullable',
'date_to' => 'date_format:Y-m-d|nullable',
'time_from' => ['nullable', 'regex:/^\d{2}:\d{2}(:\d{2})?$/'],
'time_to' => ['nullable', 'regex:/^\d{2}:\d{2}(:\d{2})?$/'],
]);
$normalizeTime = fn ($time) => $time && strlen($time) === 5 ? $time . ':00' : $time;
$from = !empty($data['date_from'])
? $data['date_from'] . ' ' . ($normalizeTime($data['time_from'] ?? null) ?? '00:00:00')
: null;
$to = !empty($data['date_to'])
? $data['date_to'] . ' ' . ($normalizeTime($data['time_to'] ?? null) ?? '23:59:59')
: null;
return [$data, $from, $to];
}
private function authLogQuery(array $data, ?string $from, ?string $to)
{
return AuthLog::query()
->when(!empty($data['event']), fn ($q) => $q->where('event', $data['event']))
->when(!empty($data['mobile']), fn ($q) => $q->where('mobile', 'like', '%' . $data['mobile'] . '%'))
->when(!empty($data['email']), fn ($q) => $q->where('email', 'like', '%' . $data['email'] . '%'))
->when(!empty($data['package_name']), fn ($q) => $q->where('package_name', $data['package_name']))
->when(isset($data['success']), fn ($q) => $q->where('success', (bool) $data['success']))
->when(!empty($data['sms_only']), fn ($q) => $q->where('sms_sent', true))
->when($from, fn ($q) => $q->where('created_at', '>=', $from))
->when($to, fn ($q) => $q->where('created_at', '<=', $to));
}
/**
* Paginated authentication event log (OTP sends/checks, logins, registers)
* with the same filter vocabulary as the stats endpoint below.
*/
public function authLogs(Request $request)
{
[$data, $from, $to] = $this->authLogFilters($request);
return $this->authLogQuery($data, $from, $to)
->with('user:id,uuid,first_name,last_name,email,mobile')
->latest()
->paginate($data['per_page'] ?? 30)
->withQueryString();
}
/**
* Aggregated authentication stats for the admin analytics section: how many
* OTPs were sent (billable SMS) vs verified, how many unique users, the
* daily breakdown, and an estimated SMS cost.
*/
public function authStats(Request $request)
{
[$data, $from, $to] = $this->authLogFilters($request);
// Cost of one verification SMS in Toman; the panel lets the admin adjust it.
$costData = $request->validate([
'sms_unit_cost' => 'integer|min:0|nullable',
]);
$unitCost = (int) ($costData['sms_unit_cost'] ?? 0);
$base = fn () => $this->authLogQuery($data, $from, $to);
// event => count, filled with zeros so the panel always sees every key
$byEvent = array_fill_keys(AuthLog::EVENTS, 0);
foreach ($base()->groupBy('event')->selectRaw('event, count(*) as aggregate')->pluck('aggregate', 'event') as $event => $count) {
$byEvent[$event] = (int) $count;
}
$smsCount = (int) $base()->where('sms_sent', true)->count();
$otpSent = $byEvent[AuthLog::EVENT_OTP_SENT];
$otpVerified = $byEvent[AuthLog::EVENT_OTP_VERIFIED];
// Daily series (app timezone) for the chart
$daily = $base()
->selectRaw('DATE(created_at) as date')
->selectRaw('count(*) as total')
->selectRaw('sum(sms_sent) as sms')
->selectRaw("sum(case when event = ? then 1 else 0 end) as otp_sent", [AuthLog::EVENT_OTP_SENT])
->selectRaw("sum(case when event = ? then 1 else 0 end) as otp_verified", [AuthLog::EVENT_OTP_VERIFIED])
->selectRaw("sum(case when event = ? then 1 else 0 end) as otp_failed", [AuthLog::EVENT_OTP_FAILED])
->groupBy('date')
->orderBy('date')
->get()
->map(fn ($row) => [
'date' => (string) $row->date,
'total' => (int) $row->total,
'sms' => (int) $row->sms,
'otp_sent' => (int) $row->otp_sent,
'otp_verified' => (int) $row->otp_verified,
'otp_failed' => (int) $row->otp_failed,
]);
// Per-package breakdown of billed SMS and successful logins
$byPackage = $base()
->whereNotNull('package_name')
->selectRaw('package_name')
->selectRaw('sum(sms_sent) as sms')
->selectRaw("sum(case when event in (?, ?, ?) then 1 else 0 end) as logins", [
AuthLog::EVENT_OTP_VERIFIED,
AuthLog::EVENT_LOGIN,
AuthLog::EVENT_GOOGLE_LOGIN,
])
->groupBy('package_name')
->get()
->map(fn ($row) => [
'package_name' => (string) $row->package_name,
'sms' => (int) $row->sms,
'logins' => (int) $row->logins,
]);
return response()->json([
'filters' => [
'date_from' => $from,
'date_to' => $to,
'event' => $data['event'] ?? null,
'package_name' => $data['package_name'] ?? null,
],
'totals' => [
'events' => (int) $base()->count(),
// Distinct mobiles that requested a login OTP
'unique_mobiles' => (int) $base()->whereNotNull('mobile')->distinct('mobile')->count('mobile'),
'unique_users' => (int) $base()->whereNotNull('user_id')->distinct('user_id')->count('user_id'),
'sms_sent' => $smsCount,
'sms_cost' => $smsCount * $unitCost,
'sms_unit_cost' => $unitCost,
'otp_sent' => $otpSent,
'otp_verified' => $otpVerified,
'otp_failed' => $byEvent[AuthLog::EVENT_OTP_FAILED],
// Share of sent OTPs that were actually verified (wasted SMS = the rest)
'otp_conversion_rate' => $otpSent > 0 ? round($otpVerified / $otpSent * 100, 1) : 0,
'logins' => $otpVerified + $byEvent[AuthLog::EVENT_LOGIN] + $byEvent[AuthLog::EVENT_GOOGLE_LOGIN],
'registers' => $byEvent[AuthLog::EVENT_REGISTER],
],
'by_event' => $byEvent,
'by_package' => $byPackage,
'daily' => $daily,
]);
}
public function getUserTransactions(Request $request, $identifier)
{
$data = $request->validate([