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') // 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([ '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); } }