From 2123adef2bbc359287d31eedb25f42aca15bdd5c Mon Sep 17 00:00:00 2001 From: AmirmahdiNourkazemi Date: Wed, 19 Aug 2026 12:10:13 +0330 Subject: [PATCH] feat: log section --- app/Http/Controllers/AdminController.php | 156 ++++++++++++++++++ app/Http/Controllers/UserController.php | 81 ++++++++- app/Models/AuthLog.php | 90 ++++++++++ ...26_08_19_000000_create_auth_logs_table.php | 44 +++++ routes/api.php | 3 + 5 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 app/Models/AuthLog.php create mode 100644 database/migrations/2026_08_19_000000_create_auth_logs_table.php diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index f64095d..ba88a7e 100755 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -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([ diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 962c677..439b143 100755 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -5,6 +5,7 @@ use App\Helpers\MobileNumberHelper; use App\Jobs\SendSMSJob; use App\Jobs\SendOTPJob; +use App\Models\AuthLog; use App\Models\PackageName; use App\Models\Product; use App\Models\Transaction; @@ -118,9 +119,23 @@ public function login(Request $request) $user = User::where($authType, $data['auth'])->first(); if (!$user || !Hash::check($data['password'], $user->password)) { + AuthLog::record(AuthLog::EVENT_LOGIN_FAILED, [ + 'user_id' => $user?->id, + 'mobile' => $authType == 'mobile' ? $data['auth'] : $user?->mobile, + 'email' => $authType == 'email' ? $data['auth'] : $user?->email, + 'package_name' => $packageName->name, + ], $request); + return response()->json(['message' => 'رمز عبور اشتباه است'], 404); } - + + AuthLog::record(AuthLog::EVENT_LOGIN, [ + 'user_id' => $user->id, + 'mobile' => $user->mobile, + 'email' => $user->email, + 'package_name' => $packageName->name, + ], $request); + // Attach package if not exists if (!$user->packageNames()->where('package_names.id', $packageName->id)->exists()) { $user->packageNames()->attach($packageName->id, [ @@ -299,9 +314,16 @@ public function register(Request $request) ]); // } + AuthLog::record(AuthLog::EVENT_REGISTER, [ + 'user_id' => $user->id, + 'mobile' => $user->mobile, + 'email' => $user->email, + 'package_name' => $packageNameValue, + ], $request); + // Create token $token = $user->createToken('token', $user->is_admin ? ['admin'] : ['user'])->plainTextToken; - + return response()->json([ 'message' => 'User registered successfully.', 'user' => $user, @@ -405,6 +427,15 @@ public function loginOTP(Request $request) $verify = Str::random(8); SendOTPJob::dispatch($data['mobile'], (string)$otpToken); + + // Billable verification SMS — logged for the admin SMS/login analytics + AuthLog::record(AuthLog::EVENT_OTP_SENT, [ + 'user_id' => $user->id, + 'mobile' => $user->mobile, + 'email' => $user->email, + 'package_name' => $packageName->name, + 'source' => $data['source'] ?? null, + ], $request); // SendSMSJob::dispatch([ // 'mobile' => $data['mobile'], // 'message' => "کد ورود به برنامه: $otpToken @@ -439,6 +470,10 @@ public function checkOTP(Request $request) ]); if (!$user = User::where('mobile', $data['mobile'])->first()) { + AuthLog::record(AuthLog::EVENT_OTP_FAILED, [ + 'mobile' => MobileNumberHelper::formatMobile($data['mobile']), + ], $request); + return response()->json([ 'message' => 'user not found' ], 404); @@ -446,12 +481,24 @@ public function checkOTP(Request $request) $otpToken = $user->otpTokens()->where('token', $data['token'])->where('created_at', '>', now()->subMinutes(15))->first(); if (!$otpToken && !($data['mobile'] == config('approo.admin_mobile') && $data['token'] == config('approo.admin_otp'))) { + AuthLog::record(AuthLog::EVENT_OTP_FAILED, [ + 'user_id' => $user->id, + 'mobile' => $user->mobile, + 'email' => $user->email, + ], $request); + return response()->json([ 'message' => 'token not valid' ], 400); } $otpToken?->delete(); + AuthLog::record(AuthLog::EVENT_OTP_VERIFIED, [ + 'user_id' => $user->id, + 'mobile' => $user->mobile, + 'email' => $user->email, + ], $request); + $token = ''; if ($user->is_admin) { $token = $user->createToken('token', ['admin'])->plainTextToken; @@ -493,6 +540,13 @@ public function requestMobileVerification(Request $request) SendOTPJob::dispatch($mobile, (string)$otp); + // Billable verification SMS + AuthLog::record(AuthLog::EVENT_MOBILE_OTP_SENT, [ + 'user_id' => $user->id, + 'mobile' => $mobile, + 'email' => $user->email, + ], $request); + return response()->json([ 'message' => 'otp sent successfully' ]); @@ -513,6 +567,12 @@ public function confirmMobileVerification(Request $request) ->first(); if (!$otp) { + AuthLog::record(AuthLog::EVENT_MOBILE_VERIFY_FAILED, [ + 'user_id' => $user->id, + 'mobile' => $mobile, + 'email' => $user->email, + ], $request); + return response()->json([ 'message' => 'invalid or expired token' ], 400); @@ -525,6 +585,12 @@ public function confirmMobileVerification(Request $request) 'mobile_verified_at' => now(), ]); + AuthLog::record(AuthLog::EVENT_MOBILE_VERIFIED, [ + 'user_id' => $user->id, + 'mobile' => $mobile, + 'email' => $user->email, + ], $request); + return response()->json([ 'message' => 'mobile verified successfully' ]); @@ -739,12 +805,19 @@ public function googleLogin(Request $request) } } + AuthLog::record(AuthLog::EVENT_GOOGLE_LOGIN, [ + 'user_id' => $user->id, + 'mobile' => $user->mobile, + 'email' => $user->email, + 'package_name' => $packageName->name, + ], $request); + $token = $user->createToken('token', ['user'])->plainTextToken; - + return response()->json([ 'user' => $user, 'token' => $token, ]); } - + } diff --git a/app/Models/AuthLog.php b/app/Models/AuthLog.php new file mode 100644 index 0000000..00acaf3 --- /dev/null +++ b/app/Models/AuthLog.php @@ -0,0 +1,90 @@ + 'boolean', + 'sms_sent' => 'boolean', + 'source' => 'integer', + ]; + + // Login OTP requested — an SMS was queued (unless the admin shortcut mobile) + public const EVENT_OTP_SENT = 'otp_sent'; + // Login OTP checked successfully — the user is now logged in + public const EVENT_OTP_VERIFIED = 'otp_verified'; + // Login OTP checked with a wrong/expired token + public const EVENT_OTP_FAILED = 'otp_failed'; + // In-app mobile verification OTP requested — also a billed SMS + public const EVENT_MOBILE_OTP_SENT = 'mobile_otp_sent'; + public const EVENT_MOBILE_VERIFIED = 'mobile_verified'; + public const EVENT_MOBILE_VERIFY_FAILED = 'mobile_verify_failed'; + // Password login + public const EVENT_LOGIN = 'login'; + public const EVENT_LOGIN_FAILED = 'login_failed'; + public const EVENT_REGISTER = 'register'; + public const EVENT_GOOGLE_LOGIN = 'google_login'; + + public const EVENTS = [ + self::EVENT_OTP_SENT, + self::EVENT_OTP_VERIFIED, + self::EVENT_OTP_FAILED, + self::EVENT_MOBILE_OTP_SENT, + self::EVENT_MOBILE_VERIFIED, + self::EVENT_MOBILE_VERIFY_FAILED, + self::EVENT_LOGIN, + self::EVENT_LOGIN_FAILED, + self::EVENT_REGISTER, + self::EVENT_GOOGLE_LOGIN, + ]; + + // Events that cost an outgoing verification SMS + public const SMS_EVENTS = [ + self::EVENT_OTP_SENT, + self::EVENT_MOBILE_OTP_SENT, + ]; + + /** + * Record an event. Never lets a logging failure break the auth flow. + */ + public static function record(string $event, array $attributes = [], ?Request $request = null): ?self + { + try { + $request ??= request(); + + return static::create(array_merge([ + 'event' => $event, + 'success' => !str_ends_with($event, '_failed'), + 'sms_sent' => in_array($event, self::SMS_EVENTS, true), + 'ip' => $request?->ip(), + 'user_agent' => substr((string) $request?->userAgent(), 0, 255) ?: null, + ], $attributes)); + } catch (\Throwable $e) { + logger()->warning('auth log failed', ['event' => $event, 'error' => $e->getMessage()]); + + return null; + } + } + + public function user() + { + return $this->belongsTo(User::class); + } + + protected function serializeDate(DateTimeInterface $date) + { + return $date->format('Y-m-d H:i:s'); + } +} diff --git a/database/migrations/2026_08_19_000000_create_auth_logs_table.php b/database/migrations/2026_08_19_000000_create_auth_logs_table.php new file mode 100644 index 0000000..3c70746 --- /dev/null +++ b/database/migrations/2026_08_19_000000_create_auth_logs_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('mobile')->nullable(); + $table->string('email')->nullable(); + // See App\Models\AuthLog::EVENTS + $table->string('event'); + $table->string('package_name')->nullable(); + // PackageName::SOURCES code the request came from (store/web/…) + $table->unsignedTinyInteger('source')->nullable(); + $table->boolean('success')->default(true); + // True when the event actually triggered an outgoing SMS (billable) + $table->boolean('sms_sent')->default(false); + $table->string('ip', 45)->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamps(); + + $table->index(['event', 'created_at']); + $table->index('mobile'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('auth_logs'); + } +}; diff --git a/routes/api.php b/routes/api.php index 153eb8a..68a6431 100755 --- a/routes/api.php +++ b/routes/api.php @@ -90,6 +90,9 @@ Route::get('/users', 'users'); // All subscription purchases (transactions) with filters Route::get('/purchases', 'purchases'); + // Authentication analytics: OTP/SMS usage and login counts + Route::get('/auth-logs', 'authLogs'); + Route::get('/auth-stats', 'authStats'); // All promotions (including inactive/expired) for admin management Route::get('/promotions', [PromotionController::class, 'adminIndex']); Route::post('/users/{mobile}/profile', 'updateUserProfile');