557 lines
21 KiB
PHP
Executable File
557 lines
21 KiB
PHP
Executable File
<?php
|
|
|
|
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
|
|
{
|
|
public function users(Request $request)
|
|
{
|
|
$data = $request->validate([
|
|
'per_page' => 'integer',
|
|
'mobile' => 'string',
|
|
'email' => 'string',
|
|
'package_name' => 'string',
|
|
]);
|
|
|
|
$users = User::when(isset($data['package_name']), fn ($q) => $q->whereHas('packageNames', fn ($q2) => $q2->where('name', $data['package_name'])))
|
|
->when(isset($data['mobile']), fn ($q) => $q->where('mobile', $data['mobile']))
|
|
->when(isset($data['email']), fn ($q) => $q->where('email', $data['email']))
|
|
->paginate($data['per_page'] ?? 30);
|
|
|
|
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')
|
|
->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([
|
|
'per_page' => 'integer'
|
|
]);
|
|
|
|
if (!$user = $this->findUserByIdentifier($identifier)) {
|
|
return response()->json([
|
|
'message' => 'user not found'
|
|
], 404);
|
|
}
|
|
|
|
$transactions = $user->transactions()->paginate($data['per_page'] ?? 30);
|
|
|
|
return $transactions;
|
|
}
|
|
|
|
public function getUserStatus(Request $request, $identifier)
|
|
{
|
|
$data = $request->validate([
|
|
'package_name' => 'string'
|
|
]);
|
|
|
|
if (isset($data['package_name']) && !$packageName = PackageName::with('products')->where('name', $data['package_name'])->first()) {
|
|
return response()->json([
|
|
'message' => 'package name not found'
|
|
], 404);
|
|
}
|
|
|
|
if (!$user = User::with([
|
|
'products' => fn ($q) => $q->when(isset($data['package_name']), fn ($q1) => $q1->where('package_name_id', $packageName->id)),
|
|
'products.packageName'
|
|
])->where(function($query) use ($identifier) {
|
|
$this->applyIdentifierCondition($query, $identifier);
|
|
})->first()) {
|
|
return response()->json([
|
|
'message' => 'user not found'
|
|
], 404);
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
public function getUserStatusV1(Request $request, $identifier)
|
|
{
|
|
$data = $request->validate([
|
|
'package_name' => 'string'
|
|
]);
|
|
|
|
$userIdentifier = $identifier;
|
|
|
|
if (isset($data['package_name']) && !$packageName = PackageName::with('products')
|
|
->where('name', $data['package_name'])->first()) {
|
|
return response()->json([
|
|
'message' => 'package name not found'
|
|
], 404);
|
|
}
|
|
|
|
// For OldPurchase, we need to determine if identifier is mobile or email
|
|
if ($this->isMobileNumber($identifier)) {
|
|
$mobile = preg_replace('/09/', '989', $identifier, 1);
|
|
$query = OldPurchase::when(isset($data['package_name']), fn ($q) => $q->where('package_name', $data['package_name']))
|
|
->where('mobile', $mobile);
|
|
} else {
|
|
// Assuming OldPurchase might have email field or we need to find user first
|
|
if (!$user = $this->findUserByIdentifier($identifier)) {
|
|
return response()->json([
|
|
'message' => 'user not found'
|
|
], 404);
|
|
}
|
|
$query = OldPurchase::when(isset($data['package_name']), fn ($q) => $q->where('package_name', $data['package_name']))
|
|
->where('mobile', preg_replace('/09/', '989', $user->mobile, 1));
|
|
}
|
|
|
|
if (!$query->first()) {
|
|
return response()->json([
|
|
'message' => 'user is not paid'
|
|
], 400);
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => 'user is paid'
|
|
]);
|
|
}
|
|
|
|
public function subscribeUser(Request $request, $identifier)
|
|
{
|
|
$data = $request->validate([
|
|
'product_id' => 'integer|required'
|
|
]);
|
|
|
|
if (!$user = $this->findUserByIdentifier($identifier)) {
|
|
return response()->json([
|
|
'message' => 'user not found'
|
|
], 404);
|
|
}
|
|
|
|
if (!$product = Product::where('id', $data['product_id'])->first()) {
|
|
return response()->json([
|
|
'message' => 'product not found'
|
|
], 404);
|
|
}
|
|
|
|
$product->buy($user);
|
|
|
|
return response()->json([
|
|
'message' => 'subscribed successfuly'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* List users who reached the referral invite target (کد معرف) and have
|
|
* not yet been granted their free-month subscription.
|
|
*/
|
|
public function pendingReferralRewards(Request $request)
|
|
{
|
|
$target = User::REFERRAL_SUBSCRIPTION_TARGET;
|
|
|
|
// Use has() (a correlated subquery in WHERE) rather than having() on the
|
|
// withCount alias — Postgres does not allow select aliases in HAVING.
|
|
$users = User::withCount('referrals')
|
|
->whereNull('referral_subscription_granted_at')
|
|
->has('referrals', '>=', $target)
|
|
->orderByDesc('referrals_count')
|
|
->get(['id', 'uuid', 'first_name', 'last_name', 'email', 'mobile', 'referral_code']);
|
|
|
|
return response()->json([
|
|
'subscription_target' => $target,
|
|
'data' => $users->map(fn ($u) => [
|
|
'id' => $u->id,
|
|
'uuid' => $u->uuid,
|
|
'name' => trim($u->first_name . ' ' . $u->last_name),
|
|
'email' => $u->email,
|
|
'mobile' => $u->mobile,
|
|
'referral_code' => $u->referral_code,
|
|
'successful_invites' => $u->referrals_count,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Grant the free-month subscription to a user who reached the referral
|
|
* invite target. Defaults to the meditation package's monthly product,
|
|
* overridable via product_id. Marks the grant so it happens only once.
|
|
*/
|
|
public function fulfillReferralReward(Request $request, $userId)
|
|
{
|
|
$data = $request->validate([
|
|
'product_id' => 'integer|nullable',
|
|
]);
|
|
|
|
if (!$user = User::find($userId)) {
|
|
return response()->json(['message' => 'user not found'], 404);
|
|
}
|
|
|
|
if ($user->referral_subscription_granted_at) {
|
|
return response()->json(['message' => 'referral subscription already granted'], 400);
|
|
}
|
|
|
|
if ($user->referrals()->count() < User::REFERRAL_SUBSCRIPTION_TARGET) {
|
|
return response()->json(['message' => 'user has not reached the referral target yet'], 400);
|
|
}
|
|
|
|
// Resolve which monthly product to grant
|
|
if (!empty($data['product_id'])) {
|
|
$product = Product::where('id', $data['product_id'])->first();
|
|
} else {
|
|
$package = PackageName::where('name', User::REFERRAL_PACKAGE_NAME)->first();
|
|
$product = $package
|
|
? Product::where('package_name_id', $package->id)
|
|
->where('type', Product::TYPES['monthly'])
|
|
->first()
|
|
: null;
|
|
}
|
|
|
|
if (!$product) {
|
|
return response()->json(['message' => 'monthly product not found'], 404);
|
|
}
|
|
|
|
$product->buy($user);
|
|
|
|
$user->referral_subscription_granted_at = now();
|
|
$user->save();
|
|
|
|
return response()->json([
|
|
'message' => 'referral subscription granted',
|
|
'user_id' => $user->id,
|
|
'product_id' => $product->id,
|
|
]);
|
|
}
|
|
|
|
public function unsubscribeUser(Request $request, $identifier)
|
|
{
|
|
$data = $request->validate([
|
|
'product_id' => 'integer|required'
|
|
]);
|
|
|
|
if (!$user = $this->findUserByIdentifier($identifier)) {
|
|
return response()->json([
|
|
'message' => 'user not found'
|
|
], 404);
|
|
}
|
|
|
|
if (!$product = Product::where('id', $data['product_id'])->first()) {
|
|
return response()->json([
|
|
'message' => 'product not found'
|
|
], 404);
|
|
}
|
|
|
|
$user->products()->updateExistingPivot($product, ['expire_at' => now()->format('Y-m-d H:i:s')]);
|
|
|
|
return response()->json([
|
|
'message' => 'unsubscribed successfuly'
|
|
]);
|
|
}
|
|
|
|
public function deleteUser(Request $request, $identifier)
|
|
{
|
|
if (!$user = $this->findUserByIdentifier($identifier)) {
|
|
return response()->json([
|
|
'message' => 'user not found'
|
|
], 404);
|
|
}
|
|
|
|
$user->delete();
|
|
|
|
return response()->json([
|
|
'message' => 'user deleted'
|
|
]);
|
|
}
|
|
|
|
public function updateUserProfile(Request $request, $identifier)
|
|
{
|
|
$data = $request->validate([
|
|
'first_name' => 'string',
|
|
'last_name' => 'string',
|
|
'email' => 'email',
|
|
'mobile' => [new MobileNumber, 'string'],
|
|
'avatar' => 'image'
|
|
]);
|
|
|
|
if (!$subjectUser = $this->findUserByIdentifier($identifier)) {
|
|
return response()->json([
|
|
'message' => 'user not found'
|
|
], 404);
|
|
}
|
|
|
|
if (isset($data['email'])) {
|
|
$user = User::where('email', $data['email'])->first();
|
|
if ($user && $data['email'] != $subjectUser->email) {
|
|
return response()->json([
|
|
'message' => 'user with given email exists'
|
|
], 400);
|
|
}
|
|
}
|
|
|
|
if (isset($data['mobile'])) {
|
|
$user = User::where('mobile', $data['mobile'])->first();
|
|
if ($user && $data['mobile'] != $subjectUser->mobile) {
|
|
return response()->json([
|
|
'message' => 'user with given mobile exists'
|
|
], 400);
|
|
}
|
|
}
|
|
|
|
if (isset($data['avatar'])) {
|
|
Storage::disk('public')->put("avatars/$subjectUser->uuid.png", file_get_contents($data['avatar']->path()));
|
|
$data['avatar'] = "storage/avatars/$subjectUser->uuid.png";
|
|
}
|
|
|
|
$subjectUser->update($data);
|
|
|
|
return response()->json([
|
|
'message' => 'user updated'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Helper method to find user by mobile or email
|
|
*/
|
|
private function findUserByIdentifier($identifier)
|
|
{
|
|
return User::where(function($query) use ($identifier) {
|
|
$this->applyIdentifierCondition($query, $identifier);
|
|
})->first();
|
|
}
|
|
|
|
/**
|
|
* Helper method to apply condition for mobile or email
|
|
*/
|
|
private function applyIdentifierCondition($query, $identifier)
|
|
{
|
|
if ($this->isMobileNumber($identifier)) {
|
|
$query->where('mobile', $identifier);
|
|
} else {
|
|
$query->where('email', $identifier);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper method to check if identifier is a mobile number
|
|
*/
|
|
private function isMobileNumber($identifier)
|
|
{
|
|
// Simple check for mobile number pattern (starts with 09 or +98 or 989)
|
|
return preg_match('/^(09|\+98|989)\d{9}$/', $identifier);
|
|
}
|
|
} |