Compare commits
24
Commits
bf0cb80bd0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4df6db170 | ||
|
|
2123adef2b | ||
|
|
315b3170c8 | ||
|
|
e9574a4ce0 | ||
|
|
c17c7e6805 | ||
|
|
4185113091 | ||
|
|
7afceffdd3 | ||
|
|
319838ecee | ||
|
|
f0d8e71722 | ||
|
|
66e759f03a | ||
|
|
80ba523f92 | ||
|
|
6c602af9eb | ||
|
|
0f5f7835e7 | ||
|
|
20d4336877 | ||
|
|
ae6b32a0b2 | ||
|
|
bc86c41578 | ||
|
|
e6b0ad6cf9 | ||
|
|
acf2ed7f18 | ||
|
|
06cdf30f0a | ||
|
|
e98c0502db | ||
|
|
bbe70011dc | ||
|
|
854bd4101f | ||
|
|
ffda368718 | ||
|
|
0edece042f |
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+299
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\AuthLog;
|
||||||
use App\Models\OldPurchase;
|
use App\Models\OldPurchase;
|
||||||
use App\Models\PackageName;
|
use App\Models\PackageName;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
|
use App\Models\Transaction;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Rules\MobileNumber;
|
use App\Rules\MobileNumber;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class AdminController extends Controller
|
class AdminController extends Controller
|
||||||
{
|
{
|
||||||
@@ -30,6 +33,221 @@ public function users(Request $request)
|
|||||||
return $users;
|
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)
|
public function getUserTransactions(Request $request, $identifier)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
@@ -140,6 +358,87 @@ public function subscribeUser(Request $request, $identifier)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
public function unsubscribeUser(Request $request, $identifier)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+42
-9
@@ -21,7 +21,7 @@ public function index(Request $request, $packageName)
|
|||||||
'message' => 'package name not found'
|
'message' => 'package name not found'
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
$products = $packageName->products;
|
$products = $packageName->products()->orderBy('sort_order')->orderBy('id')->get();
|
||||||
return $products;
|
return $products;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +33,12 @@ public function store(Request $request, $packageName)
|
|||||||
'type' => ['integer', Rule::in(Product::TYPES)],
|
'type' => ['integer', Rule::in(Product::TYPES)],
|
||||||
'descriptions' => 'array|nullable',
|
'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()) {
|
if (!$packageName = PackageName::with('products')->where('name', $packageName)->first()) {
|
||||||
@@ -42,6 +48,11 @@ public function store(Request $request, $packageName)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$product = $packageName->products()->create($data);
|
$product = $packageName->products()->create($data);
|
||||||
|
|
||||||
|
if (!empty($data['is_best_seller'])) {
|
||||||
|
$this->keepSingleBestSeller($packageName, $product->id);
|
||||||
|
}
|
||||||
|
|
||||||
return $product;
|
return $product;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +63,13 @@ public function update(Request $request, $packageName, $productId)
|
|||||||
'price' => 'integer|required',
|
'price' => 'integer|required',
|
||||||
'type' => ['integer', Rule::in(Product::TYPES)],
|
'type' => ['integer', Rule::in(Product::TYPES)],
|
||||||
'descriptions' => 'array|nullable',
|
'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()) {
|
if (!$packageName = PackageName::with('products')->where('name', $packageName)->first()) {
|
||||||
@@ -68,9 +85,25 @@ public function update(Request $request, $packageName, $productId)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$product->update($data);
|
$product->update($data);
|
||||||
|
|
||||||
|
if (!empty($data['is_best_seller'])) {
|
||||||
|
$this->keepSingleBestSeller($packageName, $product->id);
|
||||||
|
}
|
||||||
|
|
||||||
return $product;
|
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)
|
public function delete($packageName, $productId)
|
||||||
{
|
{
|
||||||
if (!$packageName = PackageName::with('products')->where('name', $packageName)->first()) {
|
if (!$packageName = PackageName::with('products')->where('name', $packageName)->first()) {
|
||||||
@@ -156,13 +189,13 @@ public function subscribe(Request $request, $packageName, $productId)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($data['gateway'] == 'myket') {
|
if ($data['gateway'] == 'myket') {
|
||||||
$service = new MyketService($product->packageName->cafeConfig);
|
// $service = new MyketService($product->packageName->cafeConfig);
|
||||||
$purchaseStatus = $service->checkPurchase($product, $data['purchase_token']);
|
// $purchaseStatus = $service->checkPurchase($product, $data['purchase_token']);
|
||||||
if (!$purchaseStatus['status']) {
|
// if (!$purchaseStatus['status']) {
|
||||||
return response()->json([
|
// return response()->json([
|
||||||
'message' => $purchaseStatus['message'],
|
// 'message' => $purchaseStatus['message'],
|
||||||
], 400);
|
// ], 400);
|
||||||
}
|
// }
|
||||||
$user->transactions()->create([
|
$user->transactions()->create([
|
||||||
'amount' => $product->price,
|
'amount' => $product->price,
|
||||||
'authority' => $data['purchase_token'],
|
'authority' => $data['purchase_token'],
|
||||||
|
|||||||
Regular → Executable
+13
@@ -27,6 +27,19 @@ public function index()
|
|||||||
return response()->json($promotions);
|
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)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
|
|||||||
Regular → Executable
+22
-8
@@ -6,19 +6,33 @@
|
|||||||
use App\Models\Reminder;
|
use App\Models\Reminder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use App\Enums\ReminderType;
|
use App\ReminderType;
|
||||||
use Illuminate\Validation\Rules\Enum;
|
use Illuminate\Validation\Rules\Enum;
|
||||||
|
|
||||||
class ReminderController extends Controller
|
class ReminderController extends Controller
|
||||||
{
|
{
|
||||||
public function index()
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$reminders = Reminder::where('user_id', Auth::id())
|
$request->validate([
|
||||||
->orderBy('time')
|
'type' => ['nullable', new Enum(ReminderType::class)],
|
||||||
->get();
|
'package_name' => ['nullable', 'exists:package_names,name'],
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json($reminders);
|
$reminders = Reminder::query()
|
||||||
}
|
->where('user_id', Auth::id())
|
||||||
|
->when($request->type, function ($q) use ($request) {
|
||||||
|
$q->where('type', $request->type);
|
||||||
|
})
|
||||||
|
->when($request->package_name, function ($q) use ($request) {
|
||||||
|
$q->whereHas('packageName', function ($q2) use ($request) {
|
||||||
|
$q2->where('name', $request->package_name);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderBy('time')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json($reminders);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
+195
-24
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
use App\Helpers\MobileNumberHelper;
|
use App\Helpers\MobileNumberHelper;
|
||||||
use App\Jobs\SendSMSJob;
|
use App\Jobs\SendSMSJob;
|
||||||
|
use App\Jobs\SendOTPJob;
|
||||||
|
use App\Models\AuthLog;
|
||||||
use App\Models\PackageName;
|
use App\Models\PackageName;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
@@ -117,9 +119,23 @@ public function login(Request $request)
|
|||||||
$user = User::where($authType, $data['auth'])->first();
|
$user = User::where($authType, $data['auth'])->first();
|
||||||
|
|
||||||
if (!$user || !Hash::check($data['password'], $user->password)) {
|
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);
|
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
|
// Attach package if not exists
|
||||||
if (!$user->packageNames()->where('package_names.id', $packageName->id)->exists()) {
|
if (!$user->packageNames()->where('package_names.id', $packageName->id)->exists()) {
|
||||||
$user->packageNames()->attach($packageName->id, [
|
$user->packageNames()->attach($packageName->id, [
|
||||||
@@ -206,7 +222,11 @@ public function register(Request $request)
|
|||||||
'mobile' => ['string', new MobileNumber, 'nullable', 'required_without:email'],
|
'mobile' => ['string', new MobileNumber, 'nullable', 'required_without:email'],
|
||||||
'fcm_token' => 'string|nullable',
|
'fcm_token' => 'string|nullable',
|
||||||
'package_name' => 'string|required',
|
'package_name' => 'string|required',
|
||||||
'method' => 'string|in:code,link|nullable'
|
'method' => 'string|in:code,link|nullable',
|
||||||
|
'birthday' => 'date|nullable',
|
||||||
|
'age' => 'integer|nullable|min:0|max:120',
|
||||||
|
'gender' => ['integer', 'nullable', Rule::in(User::GENDERS)],
|
||||||
|
'referrer_code' => ['string', 'nullable', 'exists:users,referral_code'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$method = $data['method'] ?? 'link';
|
$method = $data['method'] ?? 'link';
|
||||||
@@ -216,6 +236,13 @@ public function register(Request $request)
|
|||||||
$packageNameValue = $data['package_name'];
|
$packageNameValue = $data['package_name'];
|
||||||
unset($data['package_name']);
|
unset($data['package_name']);
|
||||||
|
|
||||||
|
// Resolve referrer code (کد معرف) to the referring user
|
||||||
|
$referrerCode = $data['referrer_code'] ?? null;
|
||||||
|
unset($data['referrer_code']);
|
||||||
|
if ($referrerCode && $referrer = User::where('referral_code', $referrerCode)->first()) {
|
||||||
|
$data['referred_by'] = $referrer->id;
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($data['mobile'])) {
|
if (isset($data['mobile'])) {
|
||||||
$data['mobile'] = MobileNumberHelper::formatMobile($data['mobile']);
|
$data['mobile'] = MobileNumberHelper::formatMobile($data['mobile']);
|
||||||
if (User::where('mobile', $data['mobile'])->exists()) {
|
if (User::where('mobile', $data['mobile'])->exists()) {
|
||||||
@@ -230,21 +257,21 @@ public function register(Request $request)
|
|||||||
$code = rand(1000, 9999);
|
$code = rand(1000, 9999);
|
||||||
$token = Str::random(64);
|
$token = Str::random(64);
|
||||||
// ✅ Check if email can receive a message before creating the user
|
// ✅ Check if email can receive a message before creating the user
|
||||||
if (isset($data['email'])) {
|
// if (isset($data['email'])) {
|
||||||
// $code = rand(1000, 9999);
|
// $code = rand(1000, 9999);
|
||||||
try {
|
// try {
|
||||||
if ($method === 'code') {
|
// if ($method === 'code') {
|
||||||
\Mail::to($data['email'])->send(new \App\Mail\VerifyEmailCodeMail($code));
|
// \Mail::to($data['email'])->send(new \App\Mail\VerifyEmailCodeMail($code));
|
||||||
} else {
|
// } else {
|
||||||
$verificationUrl = url("api/auth/verify-email-link/{$token}?package={$packageNameValue}");
|
// $verificationUrl = url("api/auth/verify-email-link/{$token}?package={$packageNameValue}");
|
||||||
\Mail::to($data['email'])->send(new \App\Mail\VerifyEmailLinkMail($verificationUrl));
|
// \Mail::to($data['email'])->send(new \App\Mail\VerifyEmailLinkMail($verificationUrl));
|
||||||
}
|
// }
|
||||||
} catch (\Exception $e) {
|
// } catch (\Exception $e) {
|
||||||
return response()->json([
|
// return response()->json([
|
||||||
'error' => $e->getMessage(),
|
// 'error' => $e->getMessage(),
|
||||||
'message' => 'لطفا آدرس ایمیل خود را بررسی کنید و مجدد تلاش کنید'
|
// 'message' => 'لطفا آدرس ایمیل خود را بررسی کنید و مجدد تلاش کنید'
|
||||||
], 400);
|
// ], 400);
|
||||||
}
|
// }
|
||||||
// try {
|
// try {
|
||||||
// // Try sending email
|
// // Try sending email
|
||||||
// \Mail::to($data['email'])->send(new \App\Mail\VerifyEmailCodeMail($code));
|
// \Mail::to($data['email'])->send(new \App\Mail\VerifyEmailCodeMail($code));
|
||||||
@@ -256,7 +283,7 @@ public function register(Request $request)
|
|||||||
|
|
||||||
// ], 400);
|
// ], 400);
|
||||||
// }
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
$data['password'] = isset($data['password'])
|
$data['password'] = isset($data['password'])
|
||||||
@@ -287,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
|
// Create token
|
||||||
$token = $user->createToken('token', $user->is_admin ? ['admin'] : ['user'])->plainTextToken;
|
$token = $user->createToken('token', $user->is_admin ? ['admin'] : ['user'])->plainTextToken;
|
||||||
|
|
||||||
@@ -392,12 +426,22 @@ public function loginOTP(Request $request)
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$verify = Str::random(8);
|
$verify = Str::random(8);
|
||||||
SendSMSJob::dispatch([
|
SendOTPJob::dispatch($data['mobile'], (string)$otpToken);
|
||||||
'mobile' => $data['mobile'],
|
|
||||||
'message' => "کد ورود به برنامه: $otpToken
|
// Billable verification SMS — logged for the admin SMS/login analytics
|
||||||
@{$packageName->web_app_url} #$otpToken
|
AuthLog::record(AuthLog::EVENT_OTP_SENT, [
|
||||||
لغو 11"
|
'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
|
||||||
|
// @{$packageName->web_app_url} #$otpToken
|
||||||
|
// لغو 11"
|
||||||
|
// ]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$user->packageNames()->where('package_names.id', $packageName->id)->exists()) {
|
if (!$user->packageNames()->where('package_names.id', $packageName->id)->exists()) {
|
||||||
@@ -426,6 +470,10 @@ public function checkOTP(Request $request)
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$user = User::where('mobile', $data['mobile'])->first()) {
|
if (!$user = User::where('mobile', $data['mobile'])->first()) {
|
||||||
|
AuthLog::record(AuthLog::EVENT_OTP_FAILED, [
|
||||||
|
'mobile' => MobileNumberHelper::formatMobile($data['mobile']),
|
||||||
|
], $request);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'user not found'
|
'message' => 'user not found'
|
||||||
], 404);
|
], 404);
|
||||||
@@ -433,12 +481,24 @@ public function checkOTP(Request $request)
|
|||||||
|
|
||||||
$otpToken = $user->otpTokens()->where('token', $data['token'])->where('created_at', '>', now()->subMinutes(15))->first();
|
$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'))) {
|
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([
|
return response()->json([
|
||||||
'message' => 'token not valid'
|
'message' => 'token not valid'
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
$otpToken?->delete();
|
$otpToken?->delete();
|
||||||
|
|
||||||
|
AuthLog::record(AuthLog::EVENT_OTP_VERIFIED, [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'mobile' => $user->mobile,
|
||||||
|
'email' => $user->email,
|
||||||
|
], $request);
|
||||||
|
|
||||||
$token = '';
|
$token = '';
|
||||||
if ($user->is_admin) {
|
if ($user->is_admin) {
|
||||||
$token = $user->createToken('token', ['admin'])->plainTextToken;
|
$token = $user->createToken('token', ['admin'])->plainTextToken;
|
||||||
@@ -451,19 +511,108 @@ public function checkOTP(Request $request)
|
|||||||
'token' => $token,
|
'token' => $token,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
public function requestMobileVerification(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'mobile' => ['required', 'string', new MobileNumber],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$mobile = MobileNumberHelper::formatMobile($data['mobile']);
|
||||||
|
|
||||||
|
// چک اینکه موبایل مال یوزر دیگه نباشه
|
||||||
|
if (User::where('mobile', $mobile)
|
||||||
|
->where('id', '!=', $user->id)
|
||||||
|
->exists()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'mobile already taken'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// حذف otp های قبلی
|
||||||
|
$user->otpTokens()->delete();
|
||||||
|
|
||||||
|
$otp = rand(10000, 99999);
|
||||||
|
|
||||||
|
$user->otpTokens()->create([
|
||||||
|
'token' => $otp
|
||||||
|
]);
|
||||||
|
|
||||||
|
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'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
public function confirmMobileVerification(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'mobile' => ['required', 'string', new MobileNumber],
|
||||||
|
'token' => 'required|string'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$mobile = MobileNumberHelper::formatMobile($data['mobile']);
|
||||||
|
|
||||||
|
$otp = $user->otpTokens()
|
||||||
|
->where('token', $data['token'])
|
||||||
|
->where('created_at', '>', now()->subMinutes(5))
|
||||||
|
->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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$otp->delete();
|
||||||
|
|
||||||
|
$user->update([
|
||||||
|
'mobile' => $mobile,
|
||||||
|
'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'
|
||||||
|
]);
|
||||||
|
}
|
||||||
public function updateProfile(Request $request)
|
public function updateProfile(Request $request)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'first_name' => 'string|nullable',
|
'first_name' => 'string|nullable',
|
||||||
'last_name' => 'string|nullable',
|
'last_name' => 'string|nullable',
|
||||||
'birthday' => 'date|nullable',
|
'birthday' => 'date|nullable',
|
||||||
|
'age' => 'integer|nullable|min:0|max:120',
|
||||||
'gender' => ['integer', 'nullable', Rule::in(User::GENDERS)],
|
'gender' => ['integer', 'nullable', Rule::in(User::GENDERS)],
|
||||||
'email' => 'email|nullable',
|
'email' => 'email|nullable',
|
||||||
'mobile' => [new MobileNumber, 'string'],
|
'mobile' => [new MobileNumber, 'string'],
|
||||||
'avatar' => 'image'
|
'avatar' => 'image',
|
||||||
|
'referrer_code' => ['string', 'nullable', 'exists:users,referral_code'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// referrer_code (کد معرف) is not a column — resolve it to referred_by below
|
||||||
|
$referrerCode = $data['referrer_code'] ?? null;
|
||||||
|
unset($data['referrer_code']);
|
||||||
|
|
||||||
$packageNameData = $request->validate([
|
$packageNameData = $request->validate([
|
||||||
'package_name' => 'string',
|
'package_name' => 'string',
|
||||||
'fcm_token' => 'string',
|
'fcm_token' => 'string',
|
||||||
@@ -498,6 +647,17 @@ public function updateProfile(Request $request)
|
|||||||
$data['avatar'] = "storage/avatars/$user->uuid.png";
|
$data['avatar'] = "storage/avatars/$user->uuid.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the referrer (کد معرف) once: only if not already referred, and never self-referral
|
||||||
|
if ($referrerCode && !$user->referred_by) {
|
||||||
|
$referrer = User::where('referral_code', $referrerCode)->first();
|
||||||
|
if ($referrer && $referrer->id === $user->id) {
|
||||||
|
return response()->json(['message' => 'you cannot use your own referral code'], 400);
|
||||||
|
}
|
||||||
|
if ($referrer) {
|
||||||
|
$data['referred_by'] = $referrer->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$user->update($data);
|
$user->update($data);
|
||||||
|
|
||||||
if ($packageName ?? null) {
|
if ($packageName ?? null) {
|
||||||
@@ -562,6 +722,10 @@ public function status(Request $request)
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose the referrer's uuid so downstream apps can resolve the
|
||||||
|
// referral relationship (کد معرف) against their own local user.
|
||||||
|
$user['referrer_uuid'] = $user->referred_by ? optional($user->referrer)->uuid : null;
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,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;
|
$token = $user->createToken('token', ['user'])->plainTextToken;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\KavenegarService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class SendOTPJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private string $mobile,
|
||||||
|
private string $token
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(KavenegarService $service)
|
||||||
|
{
|
||||||
|
$response = $service->sendVerify($this->mobile, $this->token);
|
||||||
|
|
||||||
|
logger()->info('Kavenegar response', $response);
|
||||||
|
|
||||||
|
if (!isset($response['return']) || $response['return']['status'] != 200) {
|
||||||
|
logger()->error('Kavenegar failed', $response);
|
||||||
|
throw new \Exception(json_encode($response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+4
@@ -18,6 +18,7 @@ class Product extends Model
|
|||||||
'yearly' => 2,
|
'yearly' => 2,
|
||||||
'6months' => 3,
|
'6months' => 3,
|
||||||
'monthly' => 4,
|
'monthly' => 4,
|
||||||
|
'3months' => 5,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function users()
|
public function users()
|
||||||
@@ -26,6 +27,8 @@ public function users()
|
|||||||
}
|
}
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'descriptions' => 'array',
|
'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'),
|
2 => $expireDate->addDays(30 * 12)->format('Y-m-d H:i:s'),
|
||||||
3 => $expireDate->addDays(30 * 6)->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'),
|
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()) {
|
if ($this->users()->where('users.id', $user->id)->exists()) {
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use App\ReminderType;
|
||||||
class Reminder extends Model
|
class Reminder extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
+26
-2
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\HasUuid;
|
use App\Traits\HasUuid;
|
||||||
|
use App\Traits\HasReferralCode;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use EloquentFilter\Filterable;
|
use EloquentFilter\Filterable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -13,13 +14,23 @@
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
use HasApiTokens, HasFactory, Notifiable, HasUuid, Filterable;
|
use HasApiTokens, HasFactory, Notifiable, HasUuid, HasReferralCode, Filterable;
|
||||||
|
|
||||||
const GENDERS = [
|
const GENDERS = [
|
||||||
'male' => 1,
|
'male' => 1,
|
||||||
'female' => 2,
|
'female' => 2,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Referral program (کد معرف): successful invites needed for a free month,
|
||||||
|
// and the app whose monthly product is granted as the reward.
|
||||||
|
const REFERRAL_SUBSCRIPTION_TARGET = 10;
|
||||||
|
const REFERRAL_PACKAGE_NAME = 'com.approagency.meditation';
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
protected $casts = [
|
||||||
|
'mobile_verified_at' => 'datetime',
|
||||||
|
'referral_subscription_granted_at' => 'datetime',
|
||||||
|
];
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
];
|
];
|
||||||
@@ -39,7 +50,10 @@ public function getFullNameAttribute()
|
|||||||
{
|
{
|
||||||
return $this->first_name . ' ' . $this->last_name;
|
return $this->first_name . ' ' . $this->last_name;
|
||||||
}
|
}
|
||||||
|
public function hasVerifiedMobile(): bool
|
||||||
|
{
|
||||||
|
return !is_null($this->mobile_verified_at);
|
||||||
|
}
|
||||||
public function getAvatarAttribute()
|
public function getAvatarAttribute()
|
||||||
{
|
{
|
||||||
return isset($this->attributes['avatar']) ? url($this->attributes['avatar']) : null;
|
return isset($this->attributes['avatar']) ? url($this->attributes['avatar']) : null;
|
||||||
@@ -55,6 +69,16 @@ public function otpTokens()
|
|||||||
return $this->hasMany(OtpTokens::class);
|
return $this->hasMany(OtpTokens::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function referrer()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'referred_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function referrals()
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class, 'referred_by');
|
||||||
|
}
|
||||||
|
|
||||||
public function transactions()
|
public function transactions()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Transaction::class);
|
return $this->hasMany(Transaction::class);
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class KavenegarService
|
||||||
|
{
|
||||||
|
public function sendVerify(string $mobile, string $token)
|
||||||
|
{
|
||||||
|
$apiKey = config('kave.api_key');
|
||||||
|
|
||||||
|
if (!$apiKey) {
|
||||||
|
throw new \Exception('KAVE_API_KEY is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = "https://api.kavenegar.com/v1/{$apiKey}/verify/lookup.json";
|
||||||
|
|
||||||
|
$response = Http::timeout(5)->get($url, [
|
||||||
|
'receptor' => $mobile,
|
||||||
|
'token' => $token,
|
||||||
|
'template' => config('kave.template'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger()->info('Kavenegar raw', [
|
||||||
|
'url' => $url,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Regular → Executable
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
trait HasReferralCode
|
||||||
|
{
|
||||||
|
public static function bootHasReferralCode(): void
|
||||||
|
{
|
||||||
|
static::creating(function ($model) {
|
||||||
|
if (!$model->referral_code) {
|
||||||
|
$model->referral_code = static::generateReferralCode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateReferralCode(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$code = strtoupper(Str::random(8));
|
||||||
|
} while (static::where('referral_code', $code)->exists());
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.1",
|
"php": "^8.1",
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
"kreait/laravel-firebase": "^6.0",
|
"kreait/laravel-firebase": "^6.2",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/socialite": "^5.6",
|
"laravel/socialite": "^5.6",
|
||||||
|
|||||||
+7
-7
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "93b523c73b637a7401d21d02b2b72234",
|
"content-hash": "9b0e3706cdfe46be1c8a2e2e596796a2",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "beste/clock",
|
"name": "beste/clock",
|
||||||
@@ -2299,12 +2299,12 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/kreait/laravel-firebase.git",
|
"url": "https://github.com/beste/laravel-firebase.git",
|
||||||
"reference": "1928f8dbf7882f24f9d33eaf5d4bee24b2663981"
|
"reference": "1928f8dbf7882f24f9d33eaf5d4bee24b2663981"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/kreait/laravel-firebase/zipball/1928f8dbf7882f24f9d33eaf5d4bee24b2663981",
|
"url": "https://api.github.com/repos/beste/laravel-firebase/zipball/1928f8dbf7882f24f9d33eaf5d4bee24b2663981",
|
||||||
"reference": "1928f8dbf7882f24f9d33eaf5d4bee24b2663981",
|
"reference": "1928f8dbf7882f24f9d33eaf5d4bee24b2663981",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
@@ -2358,8 +2358,8 @@
|
|||||||
"sdk"
|
"sdk"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/kreait/laravel-firebase/issues",
|
"issues": "https://github.com/beste/laravel-firebase/issues",
|
||||||
"source": "https://github.com/kreait/laravel-firebase/tree/6.2.0"
|
"source": "https://github.com/beste/laravel-firebase/tree/6.2.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -11741,12 +11741,12 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": {},
|
"stability-flags": [],
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.1"
|
"php": "^8.1"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": [],
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+10
-1
@@ -1,5 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'url' => 'https://api.kavenegar.com/v1/' . env('KAVE_API_KEY') . '/sms/send.json'
|
'base_url' => 'https://api.kavenegar.com/v1/',
|
||||||
|
'api_key' => env('KAVE_API_KEY'),
|
||||||
|
'template' => 'loginotp'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// return [
|
||||||
|
// 'url' => 'https://api.kavenegar.com/v1/' . env('KAVE_API_KEY') . '/verify/lookup.json'
|
||||||
|
// ];
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user