Compare commits
8
Commits
f0d8e71722
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4df6db170 | ||
|
|
2123adef2b | ||
|
|
315b3170c8 | ||
|
|
e9574a4ce0 | ||
|
|
c17c7e6805 | ||
|
|
4185113091 | ||
|
|
7afceffdd3 | ||
|
|
319838ecee |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(php -l app/Http/Controllers/AdminController.php)",
|
||||
"Bash(php -l routes/api.php)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/Users/amirmahdi/StudioProjects/approagency-admin-panel/types",
|
||||
"/Users/amirmahdi/StudioProjects/approagency-admin-panel/lib/api",
|
||||
"/Users/amirmahdi/StudioProjects/approagency-admin-panel/app/admin/purchases",
|
||||
"/Users/amirmahdi/StudioProjects/approagency-admin-panel/app/admin/dashboard",
|
||||
"/Users/amirmahdi/Documents/bruno/private/collections/api-approagency/Admin"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AuthLog;
|
||||
use App\Models\OldPurchase;
|
||||
use App\Models\PackageName;
|
||||
use App\Models\Product;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Rules\MobileNumber;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
@@ -30,6 +33,221 @@ public function users(Request $request)
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all subscription purchases (transactions) across users, with
|
||||
* filters for email, mobile (phone), package name and payment source
|
||||
* (gateway). Optionally narrowed to a date/time window (dates as Y-m-d,
|
||||
* times as H:i or H:i:s, both in the app timezone) used by the admin
|
||||
* accounting section. Returns paginated results with the related user,
|
||||
* product and package eager-loaded for display in the admin panel.
|
||||
*/
|
||||
public function purchases(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'per_page' => 'integer',
|
||||
'email' => 'string|nullable',
|
||||
'mobile' => 'string|nullable',
|
||||
'package_name' => 'string|nullable',
|
||||
// payment source: accepts a gateway name (asanpardakht, zarinpal,
|
||||
// digipay, cafe, myket) or its numeric code
|
||||
'gateway' => 'string|nullable',
|
||||
'status' => 'integer|nullable',
|
||||
'product_id' => 'integer|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})?$/'],
|
||||
]);
|
||||
|
||||
// Resolve the payment source to its stored integer code
|
||||
$gateway = null;
|
||||
if (!empty($data['gateway'])) {
|
||||
$gateway = Transaction::GATEWAYS[$data['gateway']]
|
||||
?? (is_numeric($data['gateway']) ? (int) $data['gateway'] : null);
|
||||
}
|
||||
|
||||
// Date/time window on created_at. A date without a time covers the
|
||||
// whole day; a time narrows the bound to the exact datetime. Times
|
||||
// only apply alongside their date (mirrors the admin panel filters).
|
||||
$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;
|
||||
|
||||
$purchases = Transaction::with(['user', 'product.packageName'])
|
||||
->when(!empty($data['email']), fn ($q) => $q->whereHas('user', fn ($u) => $u->where('email', 'like', '%' . $data['email'] . '%')))
|
||||
->when(!empty($data['mobile']), fn ($q) => $q->whereHas('user', fn ($u) => $u->where('mobile', 'like', '%' . $data['mobile'] . '%')))
|
||||
->when(!empty($data['package_name']), fn ($q) => $q->whereHas('product.packageName', fn ($p) => $p->where('name', $data['package_name'])))
|
||||
->when(!empty($data['product_id']), fn ($q) => $q->where('product_id', $data['product_id']))
|
||||
->when(!is_null($gateway), fn ($q) => $q->where('gateway', $gateway))
|
||||
->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))
|
||||
->when($from, fn ($q) => $q->where('created_at', '>=', $from))
|
||||
->when($to, fn ($q) => $q->where('created_at', '<=', $to))
|
||||
->latest()
|
||||
->paginate($data['per_page'] ?? 30)
|
||||
->withQueryString();
|
||||
|
||||
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')
|
||||
// Counted with a CASE rather than sum(sms_sent) — Postgres has no sum(boolean)
|
||||
->selectRaw('sum(case when sms_sent then 1 else 0 end) 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(case when sms_sent then 1 else 0 end) 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([
|
||||
|
||||
@@ -21,7 +21,7 @@ public function index(Request $request, $packageName)
|
||||
'message' => 'package name not found'
|
||||
], 404);
|
||||
}
|
||||
$products = $packageName->products;
|
||||
$products = $packageName->products()->orderBy('sort_order')->orderBy('id')->get();
|
||||
return $products;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ public function store(Request $request, $packageName)
|
||||
'type' => ['integer', Rule::in(Product::TYPES)],
|
||||
'descriptions' => 'array|nullable',
|
||||
'descriptions.*' => 'string|max:1000',
|
||||
// Display-only text fields (no calculation)
|
||||
'discounted_price' => 'string|nullable',
|
||||
'daily_price' => 'string|nullable',
|
||||
'discount' => 'string|nullable',
|
||||
'is_best_seller' => 'boolean|nullable',
|
||||
'sort_order' => 'integer|nullable',
|
||||
]);
|
||||
|
||||
if (!$packageName = PackageName::with('products')->where('name', $packageName)->first()) {
|
||||
@@ -42,6 +48,11 @@ public function store(Request $request, $packageName)
|
||||
}
|
||||
|
||||
$product = $packageName->products()->create($data);
|
||||
|
||||
if (!empty($data['is_best_seller'])) {
|
||||
$this->keepSingleBestSeller($packageName, $product->id);
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
@@ -52,7 +63,13 @@ public function update(Request $request, $packageName, $productId)
|
||||
'price' => 'integer|required',
|
||||
'type' => ['integer', Rule::in(Product::TYPES)],
|
||||
'descriptions' => 'array|nullable',
|
||||
'descriptions.*' => 'string|max:1000'
|
||||
'descriptions.*' => 'string|max:1000',
|
||||
// Display-only text fields (no calculation)
|
||||
'discounted_price' => 'string|nullable',
|
||||
'daily_price' => 'string|nullable',
|
||||
'discount' => 'string|nullable',
|
||||
'is_best_seller' => 'boolean|nullable',
|
||||
'sort_order' => 'integer|nullable',
|
||||
]);
|
||||
|
||||
if (!$packageName = PackageName::with('products')->where('name', $packageName)->first()) {
|
||||
@@ -68,9 +85,25 @@ public function update(Request $request, $packageName, $productId)
|
||||
}
|
||||
|
||||
$product->update($data);
|
||||
|
||||
if (!empty($data['is_best_seller'])) {
|
||||
$this->keepSingleBestSeller($packageName, $product->id);
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure only one product within a package is flagged as best-seller.
|
||||
*/
|
||||
private function keepSingleBestSeller(PackageName $packageName, $keepProductId): void
|
||||
{
|
||||
$packageName->products()
|
||||
->where('id', '!=', $keepProductId)
|
||||
->where('is_best_seller', true)
|
||||
->update(['is_best_seller' => false]);
|
||||
}
|
||||
|
||||
public function delete($packageName, $productId)
|
||||
{
|
||||
if (!$packageName = PackageName::with('products')->where('name', $packageName)->first()) {
|
||||
|
||||
@@ -27,6 +27,19 @@ public function index()
|
||||
return response()->json($promotions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin listing: returns ALL promotions regardless of is_active or the
|
||||
* start/end window, so admins can see and edit inactive/expired ones.
|
||||
*/
|
||||
public function adminIndex()
|
||||
{
|
||||
$promotions = Promotion::orderBy('priority', 'desc')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
return response()->json($promotions);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
|
||||
@@ -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,6 +314,13 @@ 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;
|
||||
|
||||
@@ -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,6 +805,13 @@ 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([
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* One row per authentication event. Used by the admin panel to count how many
|
||||
* users request/verify OTPs (i.e. how many verification SMS are billed) and how
|
||||
* many log in through each method.
|
||||
*/
|
||||
class AuthLog extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'success' => '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');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class Product extends Model
|
||||
'yearly' => 2,
|
||||
'6months' => 3,
|
||||
'monthly' => 4,
|
||||
'3months' => 5,
|
||||
];
|
||||
|
||||
public function users()
|
||||
@@ -26,6 +27,8 @@ public function users()
|
||||
}
|
||||
protected $casts = [
|
||||
'descriptions' => 'array',
|
||||
'is_best_seller' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
|
||||
@@ -51,6 +54,7 @@ public function buy($user, $purchaseToken = null, $gateway = null)
|
||||
2 => $expireDate->addDays(30 * 12)->format('Y-m-d H:i:s'),
|
||||
3 => $expireDate->addDays(30 * 6)->format('Y-m-d H:i:s'),
|
||||
4 => $expireDate->addDays(30 * 1)->format('Y-m-d H:i:s'),
|
||||
5 => $expireDate->addDays(30 * 3)->format('Y-m-d H:i:s'),
|
||||
};
|
||||
|
||||
if ($this->users()->where('users.id', $user->id)->exists()) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Display-only text fields (no calculation, just shown in the UI)
|
||||
$table->string('discounted_price')->nullable()->after('descriptions'); // قیمت کلی تخفیف خورده
|
||||
$table->string('daily_price')->nullable()->after('discounted_price'); // قیمت روزانه
|
||||
$table->string('discount')->nullable()->after('daily_price'); // تخفیف
|
||||
// Best-seller flag (only one per package should be active)
|
||||
$table->boolean('is_best_seller')->default(false)->after('discount'); // پرفروشترین
|
||||
// Display order (lower = shown higher, 0 is top)
|
||||
$table->unsignedInteger('sort_order')->default(0)->after('is_best_seller'); // ترتیب
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'discounted_price',
|
||||
'daily_price',
|
||||
'discount',
|
||||
'is_best_seller',
|
||||
'sort_order',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Persistent audit log of authentication events (OTP sends, OTP checks,
|
||||
* password logins, registrations). otp_tokens rows are deleted as soon as
|
||||
* they are consumed, so they cannot be used to count SMS usage or logins —
|
||||
* this table keeps one immutable row per event for the admin analytics.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('auth_logs', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -88,6 +88,13 @@
|
||||
Route::get('/wallet', [WalletController::class, 'getWalletAdmin']);
|
||||
Route::post('/wallet', [WalletController::class, 'chargeWalletAdmin']);
|
||||
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');
|
||||
Route::delete('/users/{mobile}', 'deleteUser');
|
||||
Route::get('/users/{mobile}/transactions', 'getUserTransactions');
|
||||
|
||||
Reference in New Issue
Block a user