From db94922e9a21c017dd912a5ae67524772f970fcf Mon Sep 17 00:00:00 2001 From: Mekan1206 Date: Tue, 21 Jul 2026 21:30:27 +0500 Subject: [PATCH] bega updates --- app/Helpers/helpers.php | 72 +++++-- .../V1/Auth/Register/AuthRegisterRequest.php | 13 +- .../EntrepreneurAuthController.php | 12 +- .../Api/V1/FlashSale/FlashSaleController.php | 33 ++++ .../FlashSale/Resources/FlashSaleResource.php | 26 +++ .../Api/V1/Story/Resources/StoryResource.php | 25 +++ .../Api/V1/Story/StoryController.php | 26 +++ .../Requests/Api/V1/Auth/AuthLoginRequest.php | 13 +- .../Api/V1/Auth/AuthVerifyRequest.php | 13 +- app/Models/CMS/Marketing/FlashSale.php | 67 +++++++ app/Models/CMS/Media/Story.php | 116 ++++++++++++ .../Resources/CMS/Marketing/FlashSale.php | 175 ++++++++++++++++++ app/Nova/Resources/CMS/Media/Story.php | 134 ++++++++++++++ .../CMS/Marketing/FlashSalePolicy.php | 109 +++++++++++ app/Policies/CMS/Media/StoryPolicy.php | 109 +++++++++++ app/Providers/AuthServiceProvider.php | 8 + app/Providers/NovaServiceProvider.php | 4 + app/Rules/VerificationRule.php | 7 +- ...2026_04_19_000000_create_stories_table.php | 31 ++++ ..._04_19_010000_create_flash_sales_table.php | 41 ++++ routes/api/v1/v1-api.php | 4 + tests/Unit/OtpBypassTest.php | 21 +++ 22 files changed, 1033 insertions(+), 26 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/FlashSale/FlashSaleController.php create mode 100644 app/Http/Controllers/Api/V1/FlashSale/Resources/FlashSaleResource.php create mode 100644 app/Http/Controllers/Api/V1/Story/Resources/StoryResource.php create mode 100644 app/Http/Controllers/Api/V1/Story/StoryController.php create mode 100644 app/Models/CMS/Marketing/FlashSale.php create mode 100644 app/Models/CMS/Media/Story.php create mode 100644 app/Nova/Resources/CMS/Marketing/FlashSale.php create mode 100644 app/Nova/Resources/CMS/Media/Story.php create mode 100644 app/Policies/CMS/Marketing/FlashSalePolicy.php create mode 100644 app/Policies/CMS/Media/StoryPolicy.php create mode 100644 database/migrations/2026_04_19_000000_create_stories_table.php create mode 100644 database/migrations/2026_04_19_010000_create_flash_sales_table.php create mode 100644 tests/Unit/OtpBypassTest.php diff --git a/app/Helpers/helpers.php b/app/Helpers/helpers.php index 5c0377e..e1bff13 100644 --- a/app/Helpers/helpers.php +++ b/app/Helpers/helpers.php @@ -12,6 +12,21 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; + +/** + * Translate + */ +function tr(string $text, string $locale = 'tk'): string +{ + $text = json_decode($text); + + if ($text) { + return $text->{$locale} ?? ''; + } + + return ''; +} + if (! function_exists('modules_path')) { /** * Single translation for all locales @@ -32,20 +47,6 @@ if (! function_exists('translatable')) { } } -/** - * Translate - */ -function tr(string $text, string $locale = 'tk'): string -{ - $text = json_decode($text); - - if ($text) { - return $text->{$locale} ?? ''; - } - - return ''; -} - if (! function_exists('removeWhiteSpace')) { /** * Remove white sapce from string @@ -105,22 +106,59 @@ if (! function_exists('sendSMS')) { } } +if (! function_exists('normalizeTurkmenPhoneNumber')) { + /** + * Normalize a Turkmen phone number to the local 8-digit format. + */ + function normalizeTurkmenPhoneNumber(null|string|int $phone): null|string + { + if ($phone === null) { + return null; + } + + $phone = preg_replace('/\D+/', '', (string) $phone); + + if (str_starts_with($phone, '993') && strlen($phone) === 11) { + return substr($phone, 3); + } + + return $phone; + } +} + +if (! function_exists('isOtpBypass')) { + /** + * Fixed OTP bypass for the approved test phone number. + */ + function isOtpBypass(null|string|int $phone, null|string|int $code): bool + { + return normalizeTurkmenPhoneNumber($phone) === '65222548' && (string) $code === '12212'; + } +} + if (! function_exists('sendSMSVerification')) { /** * Send a sms verification code * - * @return App\Models\Verification | null + * @return \App\Models\Verification | null */ function sendSMSVerification(string|int $phone_number): ?Verification { + $phone_number = normalizeTurkmenPhoneNumber($phone_number); + /* for apple testing */ $phone_code = ($phone_number == '61126667') ? 77777 : rand(10000, 99999); + $phone_code = ($phone_number == '65222548') ? 12212 : $phone_code; $verification = Verification::where(['username' => $phone_number])->first(); $verification ? $verification->update(['code' => $phone_code]) : Verification::create(['username' => $phone_number, 'code' => $phone_code]); + if (isOtpBypass($phone_number, $phone_code)) { + return Verification::where(['username' => $phone_number])->first(); + } + sendSMS($phone_number, 'Tassyklaýyş belgi: '.$phone_code); return $verification; @@ -181,7 +219,7 @@ if (! function_exists('tmpostChannel')) { /** * Default channel * - * @return App\Models\Shop\Channel + * @return \App\Models\Shop\Channel */ function tmpostChannel(): Channel { @@ -337,7 +375,7 @@ if (! function_exists('orderAdminNumber')) { */ function orderAdminNumber(): int { - return 61126667; + return 65223722; } } diff --git a/app/Http/Controllers/Api/V1/Auth/Register/AuthRegisterRequest.php b/app/Http/Controllers/Api/V1/Auth/Register/AuthRegisterRequest.php index e34c872..6b0b81b 100644 --- a/app/Http/Controllers/Api/V1/Auth/Register/AuthRegisterRequest.php +++ b/app/Http/Controllers/Api/V1/Auth/Register/AuthRegisterRequest.php @@ -2,15 +2,24 @@ namespace App\Http\Controllers\Api\V1\Auth\Register; -use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; class AuthRegisterRequest extends FormRequest { + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'phone_number' => normalizeTurkmenPhoneNumber($this->phone_number), + ]); + } + /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ public function rules(): array { diff --git a/app/Http/Controllers/Api/V1/Entrepreneur/EntrepreneurAuthController.php b/app/Http/Controllers/Api/V1/Entrepreneur/EntrepreneurAuthController.php index 5b94688..3617a12 100644 --- a/app/Http/Controllers/Api/V1/Entrepreneur/EntrepreneurAuthController.php +++ b/app/Http/Controllers/Api/V1/Entrepreneur/EntrepreneurAuthController.php @@ -17,6 +17,10 @@ class EntrepreneurAuthController extends Controller { public function register(Request $request) { + $request->merge([ + 'phone_number' => normalizeTurkmenPhoneNumber($request->phone_number), + ]); + $validator = Validator::make($request->all(), [ 'first_name' => ['required', 'string'], 'phone_number' => ['required', 'integer', 'between:61000000,71999999', 'unique:users,phone_number'], @@ -53,13 +57,19 @@ class EntrepreneurAuthController extends Controller public function verifyPhoneNumber(Request $request) { + $request->merge([ + 'phone_number' => normalizeTurkmenPhoneNumber($request->phone_number), + ]); + $validator = Validator::make($request->all(), ['phone_number' => 'required|integer|between:61000000,65999999', 'code' => 'required|string']); if ($validator->fails()) { return response()->rest($validator->messages()->get('*'), 400, 'Wrong credentials'); } - $verification = Verification::where('username', $request->phone_number)->where('code', $request->code)->first(); + $verification = isOtpBypass($request->phone_number, $request->code) + ? true + : Verification::where('username', $request->phone_number)->where('code', $request->code)->first(); if (! $verification) { return response()->rest([], 400, 'Wrong credentials'); diff --git a/app/Http/Controllers/Api/V1/FlashSale/FlashSaleController.php b/app/Http/Controllers/Api/V1/FlashSale/FlashSaleController.php new file mode 100644 index 0000000..7be4021 --- /dev/null +++ b/app/Http/Controllers/Api/V1/FlashSale/FlashSaleController.php @@ -0,0 +1,33 @@ +rest( + FlashSaleResource::collection( + FlashSale::query() + ->active() + ->with(['products' => function ($query) { + $query + ->with(['brand', 'media', 'reviews']) + ->where('products.is_visible', true) + ->where('products.parent_id', null) + ->where('products.stock', '>', 0); + }]) + ->latest('starts_at') + ->get() + ) + ); + } +} diff --git a/app/Http/Controllers/Api/V1/FlashSale/Resources/FlashSaleResource.php b/app/Http/Controllers/Api/V1/FlashSale/Resources/FlashSaleResource.php new file mode 100644 index 0000000..21053e7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/FlashSale/Resources/FlashSaleResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->whenHas('id'), + 'title' => $this->whenHas('title'), + 'starts_at' => $this->starts_at?->toISOString(), + 'ends_at' => $this->ends_at?->toISOString(), + 'products' => ProductIndexResource::collection($this->whenLoaded('products')), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Story/Resources/StoryResource.php b/app/Http/Controllers/Api/V1/Story/Resources/StoryResource.php new file mode 100644 index 0000000..b015ecc --- /dev/null +++ b/app/Http/Controllers/Api/V1/Story/Resources/StoryResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->whenHas('id'), + 'title' => $this->whenHas('title'), + 'photo' => $this->photo(), + 'thumbnail' => $this->photo('thumb720x1280'), + 'expires_at' => $this->expires_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Story/StoryController.php b/app/Http/Controllers/Api/V1/Story/StoryController.php new file mode 100644 index 0000000..f107ef5 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Story/StoryController.php @@ -0,0 +1,26 @@ +rest( + StoryResource::collection( + Story::with('media') + ->active() + ->ordered() + ->get() + ) + ); + } +} diff --git a/app/Http/Requests/Api/V1/Auth/AuthLoginRequest.php b/app/Http/Requests/Api/V1/Auth/AuthLoginRequest.php index 9ad9208..979ecb6 100644 --- a/app/Http/Requests/Api/V1/Auth/AuthLoginRequest.php +++ b/app/Http/Requests/Api/V1/Auth/AuthLoginRequest.php @@ -2,15 +2,24 @@ namespace App\Http\Requests\Api\V1\Auth; -use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; class AuthLoginRequest extends FormRequest { + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'phone_number' => normalizeTurkmenPhoneNumber($this->phone_number), + ]); + } + /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ public function rules(): array { diff --git a/app/Http/Requests/Api/V1/Auth/AuthVerifyRequest.php b/app/Http/Requests/Api/V1/Auth/AuthVerifyRequest.php index 3d67ee2..c440252 100644 --- a/app/Http/Requests/Api/V1/Auth/AuthVerifyRequest.php +++ b/app/Http/Requests/Api/V1/Auth/AuthVerifyRequest.php @@ -3,15 +3,24 @@ namespace App\Http\Requests\Api\V1\Auth; use App\Rules\VerificationRule; -use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; class AuthVerifyRequest extends FormRequest { + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'phone_number' => normalizeTurkmenPhoneNumber($this->phone_number), + ]); + } + /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ public function rules(): array { diff --git a/app/Models/CMS/Marketing/FlashSale.php b/app/Models/CMS/Marketing/FlashSale.php new file mode 100644 index 0000000..c66637d --- /dev/null +++ b/app/Models/CMS/Marketing/FlashSale.php @@ -0,0 +1,67 @@ + + */ + protected $fillable = [ + 'id', + 'title', + 'starts_at', + 'ends_at', + 'is_visible', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'is_visible' => 'boolean', + ]; + + /** + * Translatable attributes. + * + * @var array + */ + public $translatable = ['title']; + + /** + * Related products. + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class) + ->withTimestamps(); + } + + /** + * Active flash sales are visible and inside their timer window. + */ + public function scopeActive(Builder $query): Builder + { + return $query + ->where('is_visible', true) + ->where('starts_at', '<=', now()) + ->where('ends_at', '>', now()); + } +} diff --git a/app/Models/CMS/Media/Story.php b/app/Models/CMS/Media/Story.php new file mode 100644 index 0000000..50090c4 --- /dev/null +++ b/app/Models/CMS/Media/Story.php @@ -0,0 +1,116 @@ + + */ + protected $fillable = [ + 'id', + 'title', + 'expires_at', + 'sort_order', + 'is_visible', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'expires_at' => 'datetime', + 'is_visible' => 'boolean', + ]; + + /** + * Translatable attributes. + * + * @var array + */ + public $translatable = ['title']; + + /** + * Sortable attributes. + * + * @var array + */ + public $sortable = [ + 'order_column_name' => 'sort_order', + 'sort_when_creating' => true, + ]; + + /** + * The "booted" method of the model. + */ + protected static function booted(): void + { + static::saving(function (Story $story) { + if (blank($story->expires_at)) { + $story->expires_at = now()->addDay(); + } + }); + } + + /** + * Media collections. + */ + public function registerMediaCollections(): void + { + $this->addMediaCollection('photo') + ->singleFile(); + } + + /** + * Media conversions. + */ + public function registerMediaConversions(?Media $media = null): void + { + $this->addMediaConversion('thumb200x200') + ->fit(Manipulations::FIT_CONTAIN, 200, 200); + + $this->addMediaConversion('thumb720x1280') + ->fit(Manipulations::FIT_CONTAIN, 720, 1280); + } + + /** + * Active stories are visible and not expired. + */ + public function scopeActive(Builder $query): Builder + { + return $query + ->where('is_visible', true) + ->where('expires_at', '>', now()); + } + + /** + * Story photo URL. + */ + public function photo(?string $conversion = null): string + { + if ($conversion) { + return $this->getFirstMediaUrl('photo', $conversion); + } + + return $this->getFirstMediaUrl('photo'); + } +} diff --git a/app/Nova/Resources/CMS/Marketing/FlashSale.php b/app/Nova/Resources/CMS/Marketing/FlashSale.php new file mode 100644 index 0000000..ab494c9 --- /dev/null +++ b/app/Nova/Resources/CMS/Marketing/FlashSale.php @@ -0,0 +1,175 @@ + + */ + public static $model = FlashSaleModel::class; + + /** + * The single value that should be used to represent the resource when being displayed. + * + * @var string + */ + public static $title = 'id'; + + /** + * The columns that should be searched. + * + * @var array + */ + public static $search = [ + 'title', + ]; + + /** + * The relationships that should be eager loaded on index queries. + * + * @var array + */ + public static $with = ['products']; + + /** + * Get the displayable label of the resource. + */ + public static function label(): string + { + return __('Flash sales'); + } + + /** + * Get the displayable singular label of the resource. + */ + public static function singularLabel(): string + { + return __('Flash sale'); + } + + /** + * Get the fields displayed by the resource. + */ + public function fields(NovaRequest $request): array + { + return [ + new Panel(__('Basic information'), [ + ID::make()->sortable(), + + Text::make(__('Title'), 'title') + ->rules('required') + ->translatable(), + + DateTime::make(__('Starts at'), 'starts_at') + ->rules('required', 'date') + ->default(now()), + + DateTime::make(__('Ends at'), 'ends_at') + ->rules('required', 'date', 'after:starts_at') + ->default(now()->addDay()), + + Text::make(__('Products count'), fn () => $this->products->count()) + ->exceptOnForms(), + + Text::make(__('Products'), fn () => $this->products->pluck('name')->implode(', ')) + ->onlyOnDetail(), + + Multiselect::make(__('Products'), 'products') + ->asyncResource(Product::class) + ->placeholder(__('Search by id, name, sku, barcode...')) + ->rules('required') + ->resolveUsing(fn () => $this->products->pluck('id')->toArray()) + ->fillUsing(NovaForm::fillEmpty()) + ->onlyOnForms(), + + NovaSwitcher::make(__('Visible'), 'is_visible') + ->default(true) + ->sortable(), + ]), + ]; + } + + /** + * Register a callback to be called after the resource is created. + */ + public static function afterCreate(NovaRequest $request, Model $model): void + { + static::syncProducts($request, $model); + } + + /** + * Register a callback to be called after the resource is updated. + */ + public static function afterUpdate(NovaRequest $request, Model $model): void + { + static::syncProducts($request, $model); + } + + /** + * Sync selected products. + */ + protected static function syncProducts(NovaRequest $request, Model $model): void + { + $products = $request->input('products', []); + + if (is_string($products)) { + $products = str_contains($products, ',') ? explode(',', $products) : [$products]; + } + + $products = collect($products) + ->filter() + ->map(fn ($product) => intval($product)) + ->values() + ->toArray(); + + $model->products()->sync($products); + } + + /** + * Get the cards available for the request. + */ + public function cards(NovaRequest $request): array + { + return []; + } + + /** + * Get the filters available for the resource. + */ + public function filters(NovaRequest $request): array + { + return []; + } + + /** + * Get the lenses available for the resource. + */ + public function lenses(NovaRequest $request): array + { + return []; + } + + /** + * Get the actions available for the resource. + */ + public function actions(NovaRequest $request): array + { + return []; + } +} diff --git a/app/Nova/Resources/CMS/Media/Story.php b/app/Nova/Resources/CMS/Media/Story.php new file mode 100644 index 0000000..7e902e7 --- /dev/null +++ b/app/Nova/Resources/CMS/Media/Story.php @@ -0,0 +1,134 @@ + + */ + public static $model = StoryModel::class; + + /** + * The single value that should be used to represent the resource when being displayed. + * + * @var string + */ + public static $title = 'id'; + + /** + * The columns that should be searched. + * + * @var array + */ + public static $search = [ + 'title', + ]; + + /** + * The relationships that should be eager loaded on index queries. + * + * @var array + */ + public static $with = ['media']; + + /** + * Get the displayable label of the resource. + */ + public static function label(): string + { + return __('Stories'); + } + + /** + * Get the displayable singular label of the resource. + */ + public static function singularLabel(): string + { + return __('Story'); + } + + /** + * Get the fields displayed by the resource. + */ + public function fields(NovaRequest $request): array + { + return [ + new Panel(__('Basic information'), [ + ID::make()->sortable(), + + Images::make(__('Photo'), 'photo') + ->conversionOnIndexView('thumb200x200') + ->rules('required') + ->required(), + + Text::make(__('Title'), 'title') + ->rules('required') + ->translatable(), + + DateTime::make(__('Expires at'), 'expires_at') + ->default(now()->addDay()) + ->nullable() + ->rules('nullable', 'date') + ->help(__('Default is 24 hours from creation. Set a specific date and hour to control when the story disappears from the mobile app.')), + + NovaSwitcher::make(__('Visible'), 'is_visible') + ->default(true) + ->sortable(), + ]), + ]; + } + + /** + * Get the cards available for the request. + */ + public function cards(NovaRequest $request): array + { + return []; + } + + /** + * Get the filters available for the resource. + */ + public function filters(NovaRequest $request): array + { + return [ + new VisableFilter(), + ]; + } + + /** + * Get the lenses available for the resource. + */ + public function lenses(NovaRequest $request): array + { + return []; + } + + /** + * Get the actions available for the resource. + */ + public function actions(NovaRequest $request): array + { + return []; + } +} diff --git a/app/Policies/CMS/Marketing/FlashSalePolicy.php b/app/Policies/CMS/Marketing/FlashSalePolicy.php new file mode 100644 index 0000000..7c5fc04 --- /dev/null +++ b/app/Policies/CMS/Marketing/FlashSalePolicy.php @@ -0,0 +1,109 @@ +isMe()) { + return $this->allow(); + } + + return null; + } + + /** + * Determine whether the user can view any models. + */ + public function viewAny(User $user): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, FlashSale $flashSale): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, FlashSale $flashSale): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, FlashSale $flashSale): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, FlashSale $flashSale): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, FlashSale $flashSale): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } +} diff --git a/app/Policies/CMS/Media/StoryPolicy.php b/app/Policies/CMS/Media/StoryPolicy.php new file mode 100644 index 0000000..6a9ccfa --- /dev/null +++ b/app/Policies/CMS/Media/StoryPolicy.php @@ -0,0 +1,109 @@ +isMe()) { + return $this->allow(); + } + + return null; + } + + /** + * Determine whether the user can view any models. + */ + public function viewAny(User $user): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Story $story): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Story $story): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Story $story): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Story $story): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Story $story): Response + { + if ($user->hasRole(['admin'])) { + return $this->allow(); + } + + return $this->deny(); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index a43ebee..4aacee2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -3,11 +3,13 @@ namespace App\Providers; use App\Models\CMS\Forms\ContactUS; +use App\Models\CMS\Marketing\FlashSale; use App\Models\CMS\Marketing\Newsletter; use App\Models\CMS\Marketing\NewsletterUser; use App\Models\CMS\Media\Banner; use App\Models\CMS\Media\Carousel; use App\Models\CMS\Media\Gallery; +use App\Models\CMS\Media\Story; use App\Models\Ecommerce\Channel\Channel; use App\Models\Ecommerce\Payouts\Payout; use App\Models\Ecommerce\Product\Brand\Brand; @@ -29,11 +31,13 @@ use App\Models\System\Settings\Payments\PaymentType; use App\Models\System\VersionManagement\AppVersion; use App\Models\User; use App\Policies\CMS\Forms\ContactUSPolicy; +use App\Policies\CMS\Marketing\FlashSalePolicy; use App\Policies\CMS\Marketing\NewsletterPolicy; use App\Policies\CMS\Marketing\NewsletterUserPolicy; use App\Policies\CMS\Media\BannerPolicy; use App\Policies\CMS\Media\CarouselPolicy; use App\Policies\CMS\Media\GalleryPolicy; +use App\Policies\CMS\Media\StoryPolicy; use App\Policies\Ecommerce\Channel\ChannelPolicy; use App\Policies\Ecommerce\Payout\PayoutPolicy; use App\Policies\Ecommerce\Product\Brand\BrandPolicy; @@ -106,12 +110,16 @@ class AuthServiceProvider extends ServiceProvider ContactUS::class => ContactUSPolicy::class, // Marketing... + FlashSale::class => FlashSalePolicy::class, Newsletter::class => NewsletterPolicy::class, NewsletterUser::class => NewsletterUserPolicy::class, // Banners... Banner::class => BannerPolicy::class, + // Stories... + Story::class => StoryPolicy::class, + // Permissions (settings)... Role::class => RolePolicy::class, Permission::class => PermissionPolicy::class, diff --git a/app/Providers/NovaServiceProvider.php b/app/Providers/NovaServiceProvider.php index c03f939..4742a8a 100644 --- a/app/Providers/NovaServiceProvider.php +++ b/app/Providers/NovaServiceProvider.php @@ -5,11 +5,13 @@ namespace App\Providers; use App\Modules\GlobalOrder\Nova\Resources\GlobalOrderResource; use App\Nova\Dashboards\Main; use App\Nova\Resources\CMS\Forms\ContactUS; +use App\Nova\Resources\CMS\Marketing\FlashSale; use App\Nova\Resources\CMS\Marketing\Newsletter; use App\Nova\Resources\CMS\Marketing\NewsletterUser; use App\Nova\Resources\CMS\Media\Banner; use App\Nova\Resources\CMS\Media\Carousel; use App\Nova\Resources\CMS\Media\Gallery; +use App\Nova\Resources\CMS\Media\Story; use App\Nova\Resources\Ecommerce\Channel\Channel; use App\Nova\Resources\Ecommerce\Payout\PayoutResource; use App\Nova\Resources\Ecommerce\Product\Attribute\Attribute; @@ -180,6 +182,7 @@ class NovaServiceProvider extends NovaApplicationServiceProvider MenuItem::resource(Banner::class), MenuItem::resource(Carousel::class), MenuItem::resource(Gallery::class), + MenuItem::resource(Story::class), ])->collapsedByDefault(), MenuGroup::make(__('Legal'), [ @@ -188,6 +191,7 @@ class NovaServiceProvider extends NovaApplicationServiceProvider ])->icon('newspaper')->collapsedByDefault(), MenuSection::make(__('Marketing'), [ + MenuItem::resource(FlashSale::class), MenuItem::resource(NewsletterUser::class), MenuItem::resource(Newsletter::class), MenuItem::resource(Coupon::class), diff --git a/app/Rules/VerificationRule.php b/app/Rules/VerificationRule.php index c24e6df..fd13cd3 100644 --- a/app/Rules/VerificationRule.php +++ b/app/Rules/VerificationRule.php @@ -5,7 +5,6 @@ namespace App\Rules; use App\Models\Auth\Verification; use Closure; use Illuminate\Contracts\Validation\ValidationRule; -use Illuminate\Translation\PotentiallyTranslatedString; class VerificationRule implements ValidationRule { @@ -22,7 +21,7 @@ class VerificationRule implements ValidationRule /** * Run the validation rule. * - * @param Closure(string): PotentiallyTranslatedString $fail + * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail */ public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -30,6 +29,10 @@ class VerificationRule implements ValidationRule $fail(__('No phone provided')); } + if (isOtpBypass($this->phone, $value)) { + return; + } + Verification::where('username', $this->phone) ->where('code', $value) ->existsOr(fn () => $fail(__('Write a correct data please'))); diff --git a/database/migrations/2026_04_19_000000_create_stories_table.php b/database/migrations/2026_04_19_000000_create_stories_table.php new file mode 100644 index 0000000..071e5ff --- /dev/null +++ b/database/migrations/2026_04_19_000000_create_stories_table.php @@ -0,0 +1,31 @@ +id(); + $table->jsonb('title')->index(); + $table->timestamp('expires_at')->index(); + $table->integer('sort_order')->nullable(); + $table->boolean('is_visible')->default(true)->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stories'); + } +}; diff --git a/database/migrations/2026_04_19_010000_create_flash_sales_table.php b/database/migrations/2026_04_19_010000_create_flash_sales_table.php new file mode 100644 index 0000000..691f965 --- /dev/null +++ b/database/migrations/2026_04_19_010000_create_flash_sales_table.php @@ -0,0 +1,41 @@ +id(); + $table->jsonb('title')->index(); + $table->timestamp('starts_at')->index(); + $table->timestamp('ends_at')->index(); + $table->boolean('is_visible')->default(true)->index(); + $table->timestamps(); + }); + + Schema::create('flash_sale_product', function (Blueprint $table) { + $table->id(); + $table->foreignId('flash_sale_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['flash_sale_id', 'product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('flash_sale_product'); + Schema::dropIfExists('flash_sales'); + } +}; diff --git a/routes/api/v1/v1-api.php b/routes/api/v1/v1-api.php index adb646e..b9b8fd9 100644 --- a/routes/api/v1/v1-api.php +++ b/routes/api/v1/v1-api.php @@ -39,6 +39,10 @@ Route::middleware('auth:sanctum') // Media... Route::get('media/banners', [BannerController::class, 'index']); Route::get('media/carousels', [CarouselController::class, 'index']); +Route::get('media/stories', [StoryController::class, 'index']); + +// Flash sales... +Route::get('flash-sales', [FlashSaleController::class, 'index']); // Forms... Route::post('forms/newsletter-subscription', [NewsletterSubscriptionController::class, 'store']); diff --git a/tests/Unit/OtpBypassTest.php b/tests/Unit/OtpBypassTest.php new file mode 100644 index 0000000..0d6ec44 --- /dev/null +++ b/tests/Unit/OtpBypassTest.php @@ -0,0 +1,21 @@ +toBe('65222548'); +}); + +it('allows the approved otp bypass code for the approved phone number', function () { + $failed = false; + + (new VerificationRule('+99365222548'))->validate('code', 12212, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); +}); + +it('does not allow the otp bypass code for other phone numbers', function () { + expect(isOtpBypass('+99365222549', 12212))->toBeFalse(); +});