From 46ae711a2025fe1a3132f14d1ca0d4d403ff716a Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Sat, 29 Aug 2026 01:36:17 +0330 Subject: [PATCH] feat(api): add AI text-to-deck generation, wallet deposit structure, and profile/notification endpoints Fills the gaps between the admin panel screens and the backend: a full free-text + slide-count + language -> AI-generated presentation flow with new slide-cloning logic in TemplateFillService, a "my presentations" list endpoint, a swappable payment-gateway structure for wallet top-ups (fake driver for now), and avatar upload / public-profile toggle / account deletion / password change / notification preference endpoints. Co-Authored-By: Claude Sonnet 5 --- .env.example | 1 + app/Contracts/PaymentGateway.php | 21 ++ app/Enums/PaymentGatewayDriver.php | 15 + app/Enums/TemplateGenerationSource.php | 17 + app/Enums/WalletDepositStatus.php | 21 ++ .../RelationManagers/KeysRelationManager.php | 8 + .../Templates/Schemas/TemplateForm.php | 14 + .../Api/NotificationController.php | 92 +++++ .../Controllers/Api/ProfileController.php | 92 ++++- .../Api/TemplateGenerationController.php | 114 ++++++ .../Api/WalletDepositController.php | 89 +++++ .../Api/GenerateAiPresentationRequest.php | 57 +++ .../Requests/Api/InitiateDepositRequest.php | 35 ++ .../UpdateNotificationPreferencesRequest.php | 28 ++ .../Profile/ChangePasswordRequest.php | 38 ++ .../Requests/Profile/UpdateProfileRequest.php | 4 + .../NotificationPreferenceResource.php | 38 ++ .../Resources/TemplateGenerationResource.php | 23 ++ app/Http/Resources/UserResource.php | 5 + app/Http/Resources/WalletDepositResource.php | 38 ++ app/Jobs/GenerateTemplateJob.php | 30 +- app/Models/AiProvider.php | 8 + app/Models/NotificationPreference.php | 42 +++ app/Models/Template.php | 4 + app/Models/TemplateGeneration.php | 8 + app/Models/TemplateKey.php | 2 + app/Models/User.php | 19 +- app/Models/Wallet.php | 8 + app/Models/WalletDeposit.php | 39 ++ app/Providers/AppServiceProvider.php | 5 +- .../Ai/OpenAiCompatibleTextGenerator.php | 122 +++++++ app/Services/Payments/FakePaymentGateway.php | 35 ++ app/Services/TemplateFillService.php | 334 ++++++++++++++++-- app/Services/WalletDepositService.php | 61 ++++ config/app.php | 12 + ...60720_add_ai_fields_to_templates_table.php | 29 ++ ...i_fields_to_template_generations_table.php | 32 ++ ...atar_and_public_profile_to_users_table.php | 29 ++ ...d_is_repeatable_to_template_keys_table.php | 28 ++ ...28_160721_create_wallet_deposits_table.php | 35 ++ ..._create_notification_preferences_table.php | 34 ++ routes/api.php | 10 + 42 files changed, 1632 insertions(+), 44 deletions(-) create mode 100644 app/Contracts/PaymentGateway.php create mode 100644 app/Enums/PaymentGatewayDriver.php create mode 100644 app/Enums/TemplateGenerationSource.php create mode 100644 app/Enums/WalletDepositStatus.php create mode 100644 app/Http/Controllers/Api/WalletDepositController.php create mode 100644 app/Http/Requests/Api/GenerateAiPresentationRequest.php create mode 100644 app/Http/Requests/Api/InitiateDepositRequest.php create mode 100644 app/Http/Requests/Api/UpdateNotificationPreferencesRequest.php create mode 100644 app/Http/Requests/Profile/ChangePasswordRequest.php create mode 100644 app/Http/Resources/NotificationPreferenceResource.php create mode 100644 app/Http/Resources/WalletDepositResource.php create mode 100644 app/Models/NotificationPreference.php create mode 100644 app/Models/WalletDeposit.php create mode 100644 app/Services/Payments/FakePaymentGateway.php create mode 100644 app/Services/WalletDepositService.php create mode 100644 database/migrations/2026_08_28_160720_add_ai_fields_to_templates_table.php create mode 100644 database/migrations/2026_08_28_160721_add_ai_fields_to_template_generations_table.php create mode 100644 database/migrations/2026_08_28_160721_add_avatar_and_public_profile_to_users_table.php create mode 100644 database/migrations/2026_08_28_160721_add_is_repeatable_to_template_keys_table.php create mode 100644 database/migrations/2026_08_28_160721_create_wallet_deposits_table.php create mode 100644 database/migrations/2026_08_28_160722_create_notification_preferences_table.php diff --git a/.env.example b/.env.example index f2aae9d..f7ca568 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost +FRONTEND_URL=http://localhost:3000 APP_LOCALE=fa APP_FALLBACK_LOCALE=en diff --git a/app/Contracts/PaymentGateway.php b/app/Contracts/PaymentGateway.php new file mode 100644 index 0000000..1f8551d --- /dev/null +++ b/app/Contracts/PaymentGateway.php @@ -0,0 +1,21 @@ + $callbackPayload + */ + public function verify(WalletDeposit $deposit, array $callbackPayload): bool; +} diff --git a/app/Enums/PaymentGatewayDriver.php b/app/Enums/PaymentGatewayDriver.php new file mode 100644 index 0000000..41b454b --- /dev/null +++ b/app/Enums/PaymentGatewayDriver.php @@ -0,0 +1,15 @@ + 'درگاه آزمایشی', + }; + } +} diff --git a/app/Enums/TemplateGenerationSource.php b/app/Enums/TemplateGenerationSource.php new file mode 100644 index 0000000..162e140 --- /dev/null +++ b/app/Enums/TemplateGenerationSource.php @@ -0,0 +1,17 @@ + 'قالب آماده', + self::AiText => 'ساخت با هوش مصنوعی از متن', + }; + } +} diff --git a/app/Enums/WalletDepositStatus.php b/app/Enums/WalletDepositStatus.php new file mode 100644 index 0000000..c79941a --- /dev/null +++ b/app/Enums/WalletDepositStatus.php @@ -0,0 +1,21 @@ + 'در انتظار پرداخت', + self::Paid => 'پرداخت‌شده', + self::Failed => 'ناموفق', + self::Expired => 'منقضی‌شده', + }; + } +} diff --git a/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php b/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php index c914716..c7c094a 100644 --- a/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php +++ b/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php @@ -60,6 +60,11 @@ class KeysRelationManager extends RelationManager Toggle::make('is_required') ->label('اجباری') ->default(true), + Toggle::make('is_repeatable') + ->label('تکرارشونده (برای ساخت با هوش مصنوعی)') + ->helperText('اگر فعال باشد، این کلید در اسلاید تکرارشونده قرار دارد و برای هر اسلاید تولیدشده با هوش مصنوعی مقدار جداگانه دریافت می‌کند.') + ->default(false) + ->visible(fn (Get $get): bool => $get('type') === TemplateKeyType::Text->value), TextInput::make('sort_order') ->label('ترتیب نمایش') ->numeric() @@ -90,6 +95,9 @@ class KeysRelationManager extends RelationManager IconColumn::make('is_required') ->label('اجباری') ->boolean(), + IconColumn::make('is_repeatable') + ->label('تکرارشونده') + ->boolean(), TextColumn::make('sort_order') ->label('ترتیب') ->sortable(), diff --git a/app/Filament/Resources/Templates/Schemas/TemplateForm.php b/app/Filament/Resources/Templates/Schemas/TemplateForm.php index 3e044ab..a44a187 100644 --- a/app/Filament/Resources/Templates/Schemas/TemplateForm.php +++ b/app/Filament/Resources/Templates/Schemas/TemplateForm.php @@ -8,6 +8,7 @@ use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; class TemplateForm @@ -49,6 +50,19 @@ class TemplateForm Toggle::make('is_active') ->label('فعال') ->default(true), + Toggle::make('is_ai_enabled') + ->label('امکان ساخت با هوش مصنوعی از متن') + ->helperText('در صورت فعال بودن، این قالب در فرم «ساخت پاورپوینت از متن» به کاربران پیشنهاد داده می‌شود.') + ->default(false) + ->live(), + TextInput::make('ai_repeatable_slide_number') + ->label('شماره اسلاید تکرارشونده') + ->helperText('شماره اسلایدی از فایل پاورپوینت که برای هر بخش محتوای تولیدشده با هوش مصنوعی شبیه‌سازی می‌شود (مثلاً 2).') + ->numeric() + ->integer() + ->minValue(1) + ->visible(fn (Get $get): bool => (bool) $get('is_ai_enabled')) + ->required(fn (Get $get): bool => (bool) $get('is_ai_enabled')), ]); } } diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index d5aed1b..8cc3c91 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\UpdateNotificationPreferencesRequest; +use App\Http\Resources\NotificationPreferenceResource; use App\Http\Resources\NotificationResource; +use App\Models\NotificationPreference; use App\Services\NotificationService; use App\Traits\ApiResponse; use Illuminate\Http\JsonResponse; @@ -86,4 +89,93 @@ class NotificationController extends Controller return $this->success('همه نوتیفیکیشن‌ها خوانده شد.'); } + + #[OA\Get( + path: '/notifications/preferences', + description: 'Get the authenticated user notification preferences', + summary: 'Show notification preferences', + security: [['sanctum' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response( + response: 200, + description: 'Notification preferences fetched successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'تنظیمات اعلان‌ها با موفقیت دریافت شد.'), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'preferences', ref: '#/components/schemas/NotificationPreference'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + ] + )] + public function preferences(Request $request): JsonResponse + { + $preferences = $this->firstOrCreatePreferences($request); + + return $this->success('تنظیمات اعلان‌ها با موفقیت دریافت شد.', [ + 'preferences' => new NotificationPreferenceResource($preferences), + ]); + } + + #[OA\Put( + path: '/notifications/preferences', + description: 'Update the authenticated user notification preferences', + summary: 'Update notification preferences', + security: [['sanctum' => []]], + tags: ['Notifications'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/NotificationPreference') + ), + responses: [ + new OA\Response( + response: 200, + description: 'Notification preferences updated successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'تنظیمات اعلان‌ها با موفقیت به‌روزرسانی شد.'), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'preferences', ref: '#/components/schemas/NotificationPreference'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] + public function updatePreferences(UpdateNotificationPreferencesRequest $request): JsonResponse + { + $preferences = $this->firstOrCreatePreferences($request); + + $preferences->update($request->validated()); + + return $this->success('تنظیمات اعلان‌ها با موفقیت به‌روزرسانی شد.', [ + 'preferences' => new NotificationPreferenceResource($preferences->fresh()), + ]); + } + + private function firstOrCreatePreferences(Request $request): NotificationPreference + { + return $request->user()->notificationPreference()->firstOrCreate([], [ + 'comment_on_presentation' => true, + 'form_reply' => false, + 'new_follower' => false, + 'news_announcements' => false, + 'weekly_product_updates' => true, + 'weekly_blog_digest' => false, + ]); + } } diff --git a/app/Http/Controllers/Api/ProfileController.php b/app/Http/Controllers/Api/ProfileController.php index c1f8fe8..0e0afa2 100644 --- a/app/Http/Controllers/Api/ProfileController.php +++ b/app/Http/Controllers/Api/ProfileController.php @@ -3,10 +3,13 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Profile\ChangePasswordRequest; use App\Http\Requests\Profile\UpdateProfileRequest; use App\Http\Resources\UserResource; use App\Traits\ApiResponse; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; class ProfileController extends Controller @@ -89,10 +92,97 @@ class ProfileController extends Controller { $user = $request->user(); - $user->update($request->validated()); + $data = $request->safe()->except('avatar'); + + if ($request->hasFile('avatar')) { + if ($user->avatar_path) { + Storage::disk('public')->delete($user->avatar_path); + } + + $data['avatar_path'] = $request->file('avatar')->store('avatars', 'public'); + } + + $user->update($data); return $this->success('پروفایل با موفقیت به‌روزرسانی شد.', [ 'user' => new UserResource($user->fresh()), ]); } + + #[OA\Put( + path: '/profile/password', + description: 'Change the authenticated user password', + summary: 'Change password', + security: [['sanctum' => []]], + tags: ['Profile'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['current_password', 'password', 'password_confirmation'], + properties: [ + new OA\Property(property: 'current_password', type: 'string', format: 'password'), + new OA\Property(property: 'password', type: 'string', format: 'password'), + new OA\Property(property: 'password_confirmation', type: 'string', format: 'password'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Password changed successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'رمز عبور با موفقیت تغییر کرد.'), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] + public function changePassword(ChangePasswordRequest $request): JsonResponse + { + $user = $request->user(); + + $user->update(['password' => $request->validated('password')]); + + $currentTokenId = $user->currentAccessToken()?->id; + + if ($currentTokenId) { + $user->tokens()->where('id', '!=', $currentTokenId)->delete(); + } + + return $this->success('رمز عبور با موفقیت تغییر کرد.'); + } + + #[OA\Delete( + path: '/profile', + description: 'Delete the authenticated user account', + summary: 'Delete account', + security: [['sanctum' => []]], + tags: ['Profile'], + responses: [ + new OA\Response( + response: 200, + description: 'Account deleted successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'حساب کاربری با موفقیت حذف شد.'), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + ] + )] + public function destroy(Request $request): JsonResponse + { + $user = $request->user(); + + $user->tokens()->delete(); + $user->delete(); + + return $this->success('حساب کاربری با موفقیت حذف شد.'); + } } diff --git a/app/Http/Controllers/Api/TemplateGenerationController.php b/app/Http/Controllers/Api/TemplateGenerationController.php index 4499778..9b0ca6b 100644 --- a/app/Http/Controllers/Api/TemplateGenerationController.php +++ b/app/Http/Controllers/Api/TemplateGenerationController.php @@ -2,9 +2,11 @@ namespace App\Http\Controllers\Api; +use App\Enums\TemplateGenerationSource; use App\Enums\TemplateGenerationStatus; use App\Enums\TemplateKeyType; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\GenerateAiPresentationRequest; use App\Http\Requests\Api\GenerateTemplateRequest; use App\Http\Resources\TemplateGenerationResource; use App\Jobs\GenerateTemplateJob; @@ -12,6 +14,7 @@ use App\Models\Template; use App\Models\TemplateGeneration; use App\Traits\ApiResponse; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -89,6 +92,117 @@ class TemplateGenerationController extends Controller ], 202); } + #[OA\Post( + path: '/templates/{slug}/generate-from-text', + description: 'Start generating a presentation from free-form text using AI', + summary: 'Generate presentation from text (AI)', + security: [['sanctum' => []]], + tags: ['Templates'], + parameters: [ + new OA\Parameter(name: 'slug', description: 'Template slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['title', 'language', 'slide_count', 'text'], + properties: [ + new OA\Property(property: 'title', type: 'string', example: 'گزارش فروش سال ۱۴۰۴'), + new OA\Property(property: 'language', type: 'string', example: 'fa'), + new OA\Property(property: 'slide_count', type: 'integer', example: 10), + new OA\Property(property: 'text', type: 'string', example: 'متن، عناوین، بولت‌ها و قوانین موردنظر...'), + ] + ) + ), + responses: [ + new OA\Response( + response: 202, + description: 'Generation started', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'درخواست تولید پاورپوینت ثبت شد.'), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'generation', ref: '#/components/schemas/TemplateGeneration'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] + public function storeFromText(GenerateAiPresentationRequest $request, Template $template): JsonResponse + { + $generation = TemplateGeneration::query()->create([ + 'template_id' => $template->id, + 'uuid' => (string) Str::uuid(), + 'source' => TemplateGenerationSource::AiText, + 'title' => $request->validated('title'), + 'language' => $request->validated('language'), + 'slide_count' => $request->validated('slide_count'), + 'source_text' => $request->validated('text'), + 'status' => TemplateGenerationStatus::Pending, + 'requested_by' => $request->user()->id, + ]); + + GenerateTemplateJob::dispatch($generation); + + return $this->success('درخواست تولید پاورپوینت ثبت شد.', [ + 'generation' => new TemplateGenerationResource($generation), + ], 202); + } + + #[OA\Get( + path: '/generations', + description: 'List the authenticated user presentation generations (both template-based and AI-generated)', + summary: 'List my presentations', + security: [['sanctum' => []]], + tags: ['Templates'], + parameters: [ + new OA\Parameter(name: 'per_page', description: 'Items per page', in: 'query', schema: new OA\Schema(type: 'integer', example: 15)), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Generations fetched successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'پاورپوینت‌های شما با موفقیت دریافت شد.'), + new OA\Property( + property: 'data', + properties: [ + new OA\Property( + property: 'generations', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/TemplateGeneration') + ), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + ] + )] + public function index(Request $request): JsonResponse + { + $perPage = min(max((int) $request->integer('per_page', 15), 1), 100); + + $generations = TemplateGeneration::query() + ->with('template') + ->where('requested_by', $request->user()->id) + ->latest() + ->paginate($perPage); + + return $this->success('پاورپوینت‌های شما با موفقیت دریافت شد.', [ + 'generations' => TemplateGenerationResource::collection($generations)->response()->getData(true), + ]); + } + #[OA\Get( path: '/generations/{uuid}', description: 'Get the status of a presentation generation request', diff --git a/app/Http/Controllers/Api/WalletDepositController.php b/app/Http/Controllers/Api/WalletDepositController.php new file mode 100644 index 0000000..aa5c6e2 --- /dev/null +++ b/app/Http/Controllers/Api/WalletDepositController.php @@ -0,0 +1,89 @@ + []]], + tags: ['Wallet'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['amount'], + properties: [ + new OA\Property(property: 'amount', type: 'integer', example: 500000), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Deposit initiated successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'درخواست شارژ کیف پول ثبت شد.'), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'deposit', ref: '#/components/schemas/WalletDeposit'), + new OA\Property(property: 'payment_url', type: 'string'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] + public function store(InitiateDepositRequest $request): JsonResponse + { + $result = $this->walletDepositService->initiate($request->user(), $request->validated('amount')); + + return $this->success('درخواست شارژ کیف پول ثبت شد.', [ + 'deposit' => new WalletDepositResource($result['deposit']), + 'payment_url' => $result['payment_url'], + ], 201); + } + + #[OA\Get( + path: '/wallet/deposits/{deposit}/callback', + description: 'Public callback the payment gateway redirects to after a payment attempt', + summary: 'Wallet deposit callback', + tags: ['Wallet'], + parameters: [ + new OA\Parameter(name: 'deposit', description: 'Deposit id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 302, description: 'Redirects to the frontend wallet page with a status query param'), + ] + )] + public function callback(WalletDeposit $deposit, Request $request): RedirectResponse + { + $success = $this->walletDepositService->handleCallback($deposit, $request->query()); + + return redirect()->away( + rtrim((string) config('app.frontend_url'), '/').'/wallet?status='.($success ? 'success' : 'failed') + ); + } +} diff --git a/app/Http/Requests/Api/GenerateAiPresentationRequest.php b/app/Http/Requests/Api/GenerateAiPresentationRequest.php new file mode 100644 index 0000000..160dfd5 --- /dev/null +++ b/app/Http/Requests/Api/GenerateAiPresentationRequest.php @@ -0,0 +1,57 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'language' => ['required', 'string', 'in:fa,en,ar'], + 'slide_count' => ['required', 'integer', 'min:3', 'max:30'], + 'text' => ['required', 'string', 'min:20'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + /** @var Template $template */ + $template = $this->route('template'); + + if (! $template->is_ai_enabled) { + $validator->errors()->add('template', 'این قالب برای ساخت پاورپوینت با هوش مصنوعی فعال نیست.'); + } + }); + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'title.required' => 'عنوان پاورپوینت الزامی است.', + 'language.required' => 'زبان ارائه الزامی است.', + 'language.in' => 'زبان انتخاب‌شده پشتیبانی نمی‌شود.', + 'slide_count.required' => 'تعداد اسلاید الزامی است.', + 'slide_count.min' => 'تعداد اسلاید باید حداقل :min باشد.', + 'slide_count.max' => 'تعداد اسلاید باید حداکثر :max باشد.', + 'text.required' => 'متن ورودی الزامی است.', + 'text.min' => 'متن ورودی باید حداقل :min کاراکتر باشد.', + ]; + } +} diff --git a/app/Http/Requests/Api/InitiateDepositRequest.php b/app/Http/Requests/Api/InitiateDepositRequest.php new file mode 100644 index 0000000..3fdcb3d --- /dev/null +++ b/app/Http/Requests/Api/InitiateDepositRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'amount' => ['required', 'integer', 'min:10000'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'amount.required' => 'مبلغ شارژ الزامی است.', + 'amount.integer' => 'مبلغ باید عدد باشد.', + 'amount.min' => 'حداقل مبلغ شارژ :min تومان است.', + ]; + } +} diff --git a/app/Http/Requests/Api/UpdateNotificationPreferencesRequest.php b/app/Http/Requests/Api/UpdateNotificationPreferencesRequest.php new file mode 100644 index 0000000..87a5f72 --- /dev/null +++ b/app/Http/Requests/Api/UpdateNotificationPreferencesRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'comment_on_presentation' => ['sometimes', 'boolean'], + 'form_reply' => ['sometimes', 'boolean'], + 'new_follower' => ['sometimes', 'boolean'], + 'news_announcements' => ['sometimes', 'boolean'], + 'weekly_product_updates' => ['sometimes', 'boolean'], + 'weekly_blog_digest' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Profile/ChangePasswordRequest.php b/app/Http/Requests/Profile/ChangePasswordRequest.php new file mode 100644 index 0000000..7f68f71 --- /dev/null +++ b/app/Http/Requests/Profile/ChangePasswordRequest.php @@ -0,0 +1,38 @@ + + */ + public function rules(): array + { + return [ + 'current_password' => ['required', 'string', 'current_password'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'current_password.required' => 'رمز عبور فعلی الزامی است.', + 'current_password.current_password' => 'رمز عبور فعلی صحیح نیست.', + 'password.required' => 'رمز عبور جدید الزامی است.', + 'password.min' => 'رمز عبور جدید باید حداقل ۸ کاراکتر باشد.', + 'password.confirmed' => 'تکرار رمز عبور جدید مطابقت ندارد.', + ]; + } +} diff --git a/app/Http/Requests/Profile/UpdateProfileRequest.php b/app/Http/Requests/Profile/UpdateProfileRequest.php index 85ec27b..02e5608 100644 --- a/app/Http/Requests/Profile/UpdateProfileRequest.php +++ b/app/Http/Requests/Profile/UpdateProfileRequest.php @@ -25,6 +25,8 @@ class UpdateProfileRequest extends FormRequest 'email' => ['sometimes', 'required', 'string', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)], 'mobile' => ['sometimes', 'required', 'string', 'regex:/^09\d{9}$/', Rule::unique('users', 'mobile')->ignore($userId)], 'password' => ['sometimes', 'required', 'string', 'min:8'], + 'avatar' => ['sometimes', 'nullable', 'image', 'max:3072'], + 'is_public' => ['sometimes', 'nullable', 'boolean'], ]; } @@ -45,6 +47,8 @@ class UpdateProfileRequest extends FormRequest 'mobile.unique' => 'این شماره موبایل قبلاً ثبت شده است.', 'password.required' => 'رمز عبور الزامی است.', 'password.min' => 'رمز عبور باید حداقل ۸ کاراکتر باشد.', + 'avatar.image' => 'فایل ارسالی باید تصویر باشد.', + 'avatar.max' => 'حداکثر حجم تصویر ۳ مگابایت است.', ]; } } diff --git a/app/Http/Resources/NotificationPreferenceResource.php b/app/Http/Resources/NotificationPreferenceResource.php new file mode 100644 index 0000000..9f3bb5e --- /dev/null +++ b/app/Http/Resources/NotificationPreferenceResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'comment_on_presentation' => $this->comment_on_presentation, + 'form_reply' => $this->form_reply, + 'new_follower' => $this->new_follower, + 'news_announcements' => $this->news_announcements, + 'weekly_product_updates' => $this->weekly_product_updates, + 'weekly_blog_digest' => $this->weekly_blog_digest, + ]; + } +} diff --git a/app/Http/Resources/TemplateGenerationResource.php b/app/Http/Resources/TemplateGenerationResource.php index a4fbb62..21c85dc 100644 --- a/app/Http/Resources/TemplateGenerationResource.php +++ b/app/Http/Resources/TemplateGenerationResource.php @@ -11,6 +11,20 @@ use OpenApi\Attributes as OA; schema: 'TemplateGeneration', properties: [ new OA\Property(property: 'uuid', type: 'string', example: '9d6f0b1e-9a3a-4f0a-8a1a-6c9a2e5b3d10'), + new OA\Property(property: 'title', type: 'string', example: 'گزارش فروش سال ۱۴۰۴'), + new OA\Property(property: 'source', type: 'string', example: 'ai_text'), + new OA\Property(property: 'source_label', type: 'string', example: 'ساخت با هوش مصنوعی از متن'), + new OA\Property(property: 'language', type: 'string', example: 'fa', nullable: true), + new OA\Property(property: 'slide_count', type: 'integer', example: 10, nullable: true), + new OA\Property( + property: 'template', + properties: [ + new OA\Property(property: 'title', type: 'string'), + new OA\Property(property: 'slug', type: 'string'), + ], + type: 'object', + nullable: true + ), new OA\Property(property: 'status', type: 'string', example: 'completed'), new OA\Property(property: 'status_label', type: 'string', example: 'تکمیل شده'), new OA\Property(property: 'error_message', type: 'string', example: null, nullable: true), @@ -28,6 +42,15 @@ class TemplateGenerationResource extends JsonResource { return [ 'uuid' => $this->uuid, + 'title' => $this->title ?: $this->template?->title, + 'source' => $this->source->value, + 'source_label' => $this->source->label(), + 'language' => $this->language, + 'slide_count' => $this->slide_count, + 'template' => $this->template ? [ + 'title' => $this->template->title, + 'slug' => $this->template->slug, + ] : null, 'status' => $this->status->value, 'status_label' => $this->status->label(), 'error_message' => $this->error_message, diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index e59cf0e..126be0f 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -5,6 +5,7 @@ namespace App\Http\Resources; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; #[OA\Schema( @@ -15,6 +16,8 @@ use OpenApi\Attributes as OA; new OA\Property(property: 'username', type: 'string', example: 'ali'), new OA\Property(property: 'email', type: 'string', format: 'email', example: 'ali@example.com'), new OA\Property(property: 'mobile', type: 'string', example: '09123456789'), + new OA\Property(property: 'avatar_url', type: 'string', example: 'https://example.com/storage/avatars/1.png', nullable: true), + new OA\Property(property: 'is_public', type: 'boolean', example: false), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )] @@ -32,6 +35,8 @@ class UserResource extends JsonResource 'username' => $this->username, 'email' => $this->email, 'mobile' => $this->mobile, + 'avatar_url' => $this->avatar_path ? Storage::disk('public')->url($this->avatar_path) : null, + 'is_public' => $this->is_public, 'created_at' => $this->created_at?->toIso8601String(), ]; } diff --git a/app/Http/Resources/WalletDepositResource.php b/app/Http/Resources/WalletDepositResource.php new file mode 100644 index 0000000..fcdad10 --- /dev/null +++ b/app/Http/Resources/WalletDepositResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'amount' => $this->amount, + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'gateway' => $this->gateway, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/GenerateTemplateJob.php b/app/Jobs/GenerateTemplateJob.php index de9293c..d577d27 100644 --- a/app/Jobs/GenerateTemplateJob.php +++ b/app/Jobs/GenerateTemplateJob.php @@ -2,14 +2,18 @@ namespace App\Jobs; +use App\Enums\TemplateGenerationSource; use App\Enums\TemplateGenerationStatus; +use App\Models\AiProvider; use App\Models\TemplateGeneration; +use App\Services\Ai\OpenAiCompatibleTextGenerator; use App\Services\TemplateFillService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use RuntimeException; use Throwable; class GenerateTemplateJob implements ShouldQueue @@ -18,12 +22,14 @@ class GenerateTemplateJob implements ShouldQueue public function __construct(private readonly TemplateGeneration $generation) {} - public function handle(TemplateFillService $templateFillService): void + public function handle(TemplateFillService $templateFillService, OpenAiCompatibleTextGenerator $outlineGenerator): void { $this->generation->update(['status' => TemplateGenerationStatus::Processing]); try { - $outputPath = $templateFillService->fill($this->generation); + $outputPath = $this->generation->source === TemplateGenerationSource::AiText + ? $this->fillFromAiText($templateFillService, $outlineGenerator) + : $templateFillService->fill($this->generation); $this->generation->update([ 'status' => TemplateGenerationStatus::Completed, @@ -36,4 +42,24 @@ class GenerateTemplateJob implements ShouldQueue ]); } } + + private function fillFromAiText(TemplateFillService $templateFillService, OpenAiCompatibleTextGenerator $outlineGenerator): string + { + $provider = AiProvider::resolveActiveDefault(); + + if (! $provider) { + throw new RuntimeException('هیچ سرویس هوش مصنوعی فعالی تنظیم نشده است.'); + } + + $outline = $outlineGenerator->generateOutline( + $this->generation->template, + (string) $this->generation->title, + (string) $this->generation->source_text, + (int) $this->generation->slide_count, + (string) $this->generation->language, + $provider, + ); + + return $templateFillService->fillAiGeneration($this->generation, $outline); + } } diff --git a/app/Models/AiProvider.php b/app/Models/AiProvider.php index e96059e..a373002 100644 --- a/app/Models/AiProvider.php +++ b/app/Models/AiProvider.php @@ -11,6 +11,14 @@ use Illuminate\Database\Eloquent\Model; #[Hidden(['api_key'])] class AiProvider extends Model { + public static function resolveActiveDefault(): ?self + { + return static::query() + ->where('is_active', true) + ->orderByDesc('is_default') + ->first(); + } + public function resolvedBaseUrl(): string { return $this->base_url ?: $this->driver->defaultBaseUrl(); diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php new file mode 100644 index 0000000..c8abd35 --- /dev/null +++ b/app/Models/NotificationPreference.php @@ -0,0 +1,42 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'comment_on_presentation' => 'boolean', + 'form_reply' => 'boolean', + 'new_follower' => 'boolean', + 'news_announcements' => 'boolean', + 'weekly_product_updates' => 'boolean', + 'weekly_blog_digest' => 'boolean', + ]; + } +} diff --git a/app/Models/Template.php b/app/Models/Template.php index f60efb2..da6352a 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'file_path', 'thumbnail_path', 'is_active', + 'is_ai_enabled', + 'ai_repeatable_slide_number', 'created_by', ])] class Template extends Model @@ -58,6 +60,8 @@ class Template extends Model { return [ 'is_active' => 'boolean', + 'is_ai_enabled' => 'boolean', + 'ai_repeatable_slide_number' => 'integer', ]; } } diff --git a/app/Models/TemplateGeneration.php b/app/Models/TemplateGeneration.php index 8bd8870..e8982b7 100644 --- a/app/Models/TemplateGeneration.php +++ b/app/Models/TemplateGeneration.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\TemplateGenerationSource; use App\Enums\TemplateGenerationStatus; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; @@ -10,6 +11,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Fillable([ 'template_id', 'uuid', + 'source', + 'title', + 'language', + 'slide_count', + 'source_text', 'status', 'values', 'output_path', @@ -40,8 +46,10 @@ class TemplateGeneration extends Model protected function casts(): array { return [ + 'source' => TemplateGenerationSource::class, 'status' => TemplateGenerationStatus::class, 'values' => 'array', + 'slide_count' => 'integer', ]; } } diff --git a/app/Models/TemplateKey.php b/app/Models/TemplateKey.php index ae600c0..8f9be98 100644 --- a/app/Models/TemplateKey.php +++ b/app/Models/TemplateKey.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'type', 'shape_name', 'is_required', + 'is_repeatable', 'sort_order', 'description', ])] @@ -47,6 +48,7 @@ class TemplateKey extends Model return [ 'type' => TemplateKeyType::class, 'is_required' => 'boolean', + 'is_repeatable' => 'boolean', 'sort_order' => 'integer', ]; } diff --git a/app/Models/User.php b/app/Models/User.php index dd2a38b..064c586 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,7 +17,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -#[Fillable(['name', 'username', 'email', 'mobile', 'password'])] +#[Fillable(['name', 'username', 'email', 'mobile', 'password', 'avatar_path', 'is_public'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable implements FilamentUser { @@ -56,6 +56,22 @@ class User extends Authenticatable implements FilamentUser return $this->hasMany(Notification::class)->latest(); } + /** + * @return HasOne + */ + public function notificationPreference(): HasOne + { + return $this->hasOne(NotificationPreference::class); + } + + /** + * @return HasMany + */ + public function templateGenerations(): HasMany + { + return $this->hasMany(TemplateGeneration::class, 'requested_by')->latest(); + } + public function hasRole(string|array $roles): bool { $roles = (array) $roles; @@ -87,6 +103,7 @@ class User extends Authenticatable implements FilamentUser return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'is_public' => 'boolean', ]; } } diff --git a/app/Models/Wallet.php b/app/Models/Wallet.php index d2e09de..a609d06 100644 --- a/app/Models/Wallet.php +++ b/app/Models/Wallet.php @@ -26,6 +26,14 @@ class Wallet extends Model return $this->hasMany(WalletTransaction::class)->latest(); } + /** + * @return HasMany + */ + public function deposits(): HasMany + { + return $this->hasMany(WalletDeposit::class)->latest(); + } + /** * @return array */ diff --git a/app/Models/WalletDeposit.php b/app/Models/WalletDeposit.php new file mode 100644 index 0000000..5e24720 --- /dev/null +++ b/app/Models/WalletDeposit.php @@ -0,0 +1,39 @@ + + */ + public function wallet(): BelongsTo + { + return $this->belongsTo(Wallet::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'amount' => 'integer', + 'status' => WalletDepositStatus::class, + 'meta' => 'array', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 63679b6..266f2d9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Contracts\PaymentGateway; use App\Models\Notification; use App\Models\Permission; use App\Models\Role; @@ -15,6 +16,7 @@ use App\Policies\RolePolicy; use App\Policies\UserPolicy; use App\Policies\WalletPolicy; use App\Policies\WalletTransactionPolicy; +use App\Services\Payments\FakePaymentGateway; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; @@ -26,7 +28,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // Swap this binding for a real gateway (ZarinPal, Zibal, ...) once one is integrated. + $this->app->bind(PaymentGateway::class, FakePaymentGateway::class); } /** diff --git a/app/Services/Ai/OpenAiCompatibleTextGenerator.php b/app/Services/Ai/OpenAiCompatibleTextGenerator.php index 25b9d95..9628ddc 100644 --- a/app/Services/Ai/OpenAiCompatibleTextGenerator.php +++ b/app/Services/Ai/OpenAiCompatibleTextGenerator.php @@ -74,6 +74,128 @@ class OpenAiCompatibleTextGenerator return $values; } + /** + * Generates a full slide outline from free-form text: a set of fixed "title_values" + * (filled once for the whole deck) plus exactly $slideCount "slides", each carrying + * its own values for the template's repeatable keys. + * + * @return array{title_values: array, slides: array>} + */ + public function generateOutline(Template $template, string $title, string $sourceText, int $slideCount, string $language, AiProvider $provider): array + { + $textKeys = $template->keys->where('type', TemplateKeyType::Text); + $fixedKeys = $textKeys->where('is_repeatable', false); + $repeatableKeys = $textKeys->where('is_repeatable', true); + + if ($repeatableKeys->isEmpty()) { + throw new RuntimeException('کلید تکرارشونده‌ای برای این قالب تعریف نشده است.'); + } + + $describeKeys = fn ($keys): string => $keys + ->map(fn (TemplateKey $key): string => sprintf('- %s (%s)%s', $key->key, $key->label, $key->description ? ': '.$key->description : '')) + ->implode("\n"); + + $fixedKeysDescription = $fixedKeys->isEmpty() ? 'هیچ' : $describeKeys($fixedKeys); + $repeatableKeysDescription = $describeKeys($repeatableKeys); + + $systemPrompt = <<api_key) + ->timeout(120) + ->post(rtrim($provider->resolvedBaseUrl(), '/').'/chat/completions', [ + 'model' => $provider->model, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userPrompt], + ], + ]); + + if ($response->failed()) { + throw new RuntimeException('خطا در ارتباط با سرویس هوش مصنوعی: '.$response->body()); + } + + $decoded = json_decode((string) $response->json('choices.0.message.content'), true); + + if (! is_array($decoded) || ! is_array($decoded['slides'] ?? null)) { + throw new RuntimeException('پاسخ هوش مصنوعی قابل تفسیر نبود.'); + } + + $normalizedTitleValues = $this->normalizeValueMap(is_array($decoded['title_values'] ?? null) ? $decoded['title_values'] : []); + + $titleValues = []; + + foreach ($fixedKeys as $key) { + $normalizedKey = $this->normalizeKey($key->key); + + if (array_key_exists($normalizedKey, $normalizedTitleValues)) { + $titleValues[$key->key] = (string) $normalizedTitleValues[$normalizedKey]; + } + } + + $slides = []; + + foreach ($decoded['slides'] as $slideData) { + if (! is_array($slideData)) { + continue; + } + + $normalizedSlideData = $this->normalizeValueMap($slideData); + $slideValues = []; + + foreach ($repeatableKeys as $key) { + $normalizedKey = $this->normalizeKey($key->key); + + if (array_key_exists($normalizedKey, $normalizedSlideData)) { + $slideValues[$key->key] = (string) $normalizedSlideData[$normalizedKey]; + } + } + + $slides[] = $slideValues; + } + + if ($slides === []) { + throw new RuntimeException('هوش مصنوعی محتوایی برای اسلایدها تولید نکرد.'); + } + + return [ + 'title_values' => $titleValues, + 'slides' => array_slice($slides, 0, $slideCount), + ]; + } + + /** + * @param array $values + * @return array + */ + private function normalizeValueMap(array $values): array + { + $normalized = []; + + foreach ($values as $key => $value) { + $normalized[$this->normalizeKey((string) $key)] = $value; + } + + return $normalized; + } + private function normalizeKey(string $key): string { return mb_strtolower(trim($key, "{} \t\n\r\0\x0B")); diff --git a/app/Services/Payments/FakePaymentGateway.php b/app/Services/Payments/FakePaymentGateway.php new file mode 100644 index 0000000..d38bd16 --- /dev/null +++ b/app/Services/Payments/FakePaymentGateway.php @@ -0,0 +1,35 @@ + redirect -> callback -> credit flow is testable end-to-end + * without any external credentials. Swap the PaymentGateway binding in + * AppServiceProvider to replace it with a real driver. + */ +class FakePaymentGateway implements PaymentGateway +{ + public function initiate(WalletDeposit $deposit): string + { + $deposit->update(['reference_id' => (string) Str::uuid()]); + + return route('api.wallet.deposits.callback', [ + 'deposit' => $deposit->id, + 'reference_id' => $deposit->reference_id, + 'status' => 'success', + ]); + } + + public function verify(WalletDeposit $deposit, array $callbackPayload): bool + { + return $deposit->reference_id !== null + && ($callbackPayload['reference_id'] ?? null) === $deposit->reference_id + && ($callbackPayload['status'] ?? null) === 'success'; + } +} diff --git a/app/Services/TemplateFillService.php b/app/Services/TemplateFillService.php index bb5c405..db41bec 100644 --- a/app/Services/TemplateFillService.php +++ b/app/Services/TemplateFillService.php @@ -25,6 +25,12 @@ class TemplateFillService private const NS_RELATIONSHIPS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; + private const NS_CONTENT_TYPES = 'http://schemas.openxmlformats.org/package/2006/content-types'; + + private const NS_PACKAGE_RELATIONSHIPS = 'http://schemas.openxmlformats.org/package/2006/relationships'; + + private const SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml'; + public function fill(TemplateGeneration $generation): string { $template = $generation->template; @@ -64,27 +70,8 @@ class TemplateFillService } try { - $slideEntries = $this->findSlideEntries($zip); - - foreach ($slideEntries as $slideEntry) { - $xml = $zip->getFromName($slideEntry); - - if ($xml === false) { - continue; - } - - if ($textValues !== []) { - $newXml = $this->replaceTextInSlideXml($xml, $textValues); - - if ($newXml !== null) { - $zip->addFromString($slideEntry, $newXml); - $xml = $newXml; - } - } - - foreach ($imageValues as $shapeName => $imagePath) { - $this->replaceShapeImage($zip, $slideEntry, $xml, $shapeName, $imagePath); - } + foreach ($this->findSlideEntries($zip) as $slideEntry) { + $this->applyValuesToSlide($zip, $slideEntry, $textValues, $imageValues); } } finally { $zip->close(); @@ -93,6 +80,287 @@ class TemplateFillService return $outputRelativePath; } + /** + * Generates a presentation from AI-produced content by cloning the template's + * designated repeatable slide once per outline slide, then filling each slide + * (repeatable and fixed alike) with its corresponding text values. Unlike fill(), + * this can change the slide count, since the outline's slide count is user-chosen. + * + * @param array{title_values: array, slides: array>} $outline + */ + public function fillAiGeneration(TemplateGeneration $generation, array $outline): string + { + $template = $generation->template; + + if ($template->ai_repeatable_slide_number === null) { + throw new RuntimeException('اسلاید تکرارشونده برای این قالب تنظیم نشده است.'); + } + + $sourceAbsolutePath = Storage::disk('local')->path($template->file_path); + + $outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx'; + $outputAbsolutePath = Storage::disk('local')->path($outputRelativePath); + + Storage::disk('local')->makeDirectory('templates/generations'); + + if (! copy($sourceAbsolutePath, $outputAbsolutePath)) { + throw new RuntimeException('امکان ساخت فایل خروجی وجود ندارد.'); + } + + $textKeys = $template->keys->where('type', TemplateKeyType::Text); + $repeatableKeys = $textKeys->where('is_repeatable', true); + $fixedKeys = $textKeys->where('is_repeatable', false); + + $titleValues = []; + + foreach ($fixedKeys as $key) { + if (array_key_exists($key->key, $outline['title_values'] ?? [])) { + $titleValues[$key->key] = (string) $outline['title_values'][$key->key]; + } + } + + $slides = array_values($outline['slides'] ?? []); + $repeatCount = count($slides); + + $zip = new ZipArchive; + + if ($zip->open($outputAbsolutePath) !== true) { + throw new RuntimeException('باز کردن فایل پاورپوینت با خطا مواجه شد.'); + } + + try { + $repeatableSlideEntry = sprintf('ppt/slides/slide%d.xml', $template->ai_repeatable_slide_number); + $repeatableEntries = [$repeatableSlideEntry]; + + if ($repeatCount > 1) { + $repeatableEntries = [...$repeatableEntries, ...$this->cloneSlide($zip, $repeatableSlideEntry, $repeatCount - 1)]; + } + + foreach ($this->findSlideEntries($zip) as $slideEntry) { + $repeatableIndex = array_search($slideEntry, $repeatableEntries, true); + + if ($repeatableIndex === false) { + $this->applyValuesToSlide($zip, $slideEntry, $titleValues, []); + + continue; + } + + $slideValues = []; + + foreach ($repeatableKeys as $key) { + if (array_key_exists($key->key, $slides[$repeatableIndex] ?? [])) { + $slideValues[$key->key] = (string) $slides[$repeatableIndex][$key->key]; + } + } + + $this->applyValuesToSlide($zip, $slideEntry, $slideValues, []); + } + } finally { + $zip->close(); + } + + return $outputRelativePath; + } + + /** + * @param array $textValues + * @param array $imageValues + */ + private function applyValuesToSlide(ZipArchive $zip, string $slideEntry, array $textValues, array $imageValues): void + { + $xml = $zip->getFromName($slideEntry); + + if ($xml === false) { + return; + } + + if ($textValues !== []) { + $newXml = $this->replaceTextInSlideXml($xml, $textValues); + + if ($newXml !== null) { + $zip->addFromString($slideEntry, $newXml); + $xml = $newXml; + } + } + + foreach ($imageValues as $shapeName => $imagePath) { + $this->replaceShapeImage($zip, $slideEntry, $xml, $shapeName, $imagePath); + } + } + + /** + * Clones the given slide entry $times additional times, registering each clone in + * presentation.xml, presentation.xml.rels and [Content_Types].xml so PowerPoint + * recognizes them as real slides, positioned immediately after the source slide in + * deck order. Internal shape/paragraph ids inside the cloned XML are left as-is — + * they only need to be unique within their own slide part, not deck-wide. + * + * @return array the newly created slide entry names, in order + */ + private function cloneSlide(ZipArchive $zip, string $sourceSlideEntry, int $times): array + { + if ($times <= 0) { + return []; + } + + $sourceSlideXml = $zip->getFromName($sourceSlideEntry); + + if ($sourceSlideXml === false) { + throw new RuntimeException('اسلاید الگو برای شبیه‌سازی یافت نشد.'); + } + + $sourceRelsEntry = 'ppt/slides/_rels/'.basename($sourceSlideEntry).'.rels'; + $sourceRelsXml = $zip->getFromName($sourceRelsEntry); + + $presentationXml = $zip->getFromName('ppt/presentation.xml'); + $presentationRelsXml = $zip->getFromName('ppt/_rels/presentation.xml.rels'); + $contentTypesXml = $zip->getFromName('[Content_Types].xml'); + + if ($presentationXml === false || $presentationRelsXml === false || $contentTypesXml === false) { + throw new RuntimeException('ساختار فایل پاورپوینت نامعتبر است.'); + } + + $presentationDom = $this->loadXml($presentationXml); + $relsDom = $this->loadXml($presentationRelsXml); + $contentTypesDom = $this->loadXml($contentTypesXml); + + if ($presentationDom === null || $relsDom === null || $contentTypesDom === null) { + throw new RuntimeException('خواندن ساختار فایل پاورپوینت با خطا مواجه شد.'); + } + + $sourceRelId = $this->findSlideRelationshipId($relsDom, $sourceSlideEntry); + + if ($sourceRelId === null) { + throw new RuntimeException('ارتباط اسلاید الگو با فایل ارائه یافت نشد.'); + } + + $presentationXpath = new DOMXPath($presentationDom); + $presentationXpath->registerNamespace('p', self::NS_PRESENTATION); + $presentationXpath->registerNamespace('r', self::NS_RELATIONSHIPS); + + $sourceSldIdEl = null; + + foreach ($presentationXpath->query('//p:sldIdLst/p:sldId') as $sldIdEl) { + if ($sldIdEl->getAttributeNS(self::NS_RELATIONSHIPS, 'id') === $sourceRelId) { + $sourceSldIdEl = $sldIdEl; + + break; + } + } + + if ($sourceSldIdEl === null) { + throw new RuntimeException('اسلاید الگو در فهرست اسلایدها یافت نشد.'); + } + + $nextSlideNumber = $this->nextSlideNumber($zip); + $nextRelIndex = $this->maxRelationshipId($relsDom) + 1; + $nextSlideId = $this->maxSlideId($presentationDom) + 1; + + $newEntries = []; + $insertAfter = $sourceSldIdEl; + + for ($i = 0; $i < $times; $i++) { + $newSlideNumber = $nextSlideNumber++; + $newSlideEntry = "ppt/slides/slide{$newSlideNumber}.xml"; + $newRelsEntry = "ppt/slides/_rels/slide{$newSlideNumber}.xml.rels"; + $newRelId = 'rId'.$nextRelIndex++; + $newSlideId = (string) $nextSlideId++; + + $zip->addFromString($newSlideEntry, $sourceSlideXml); + + if ($sourceRelsXml !== false) { + $zip->addFromString($newRelsEntry, $sourceRelsXml); + } + + $override = $contentTypesDom->createElementNS(self::NS_CONTENT_TYPES, 'Override'); + $override->setAttribute('PartName', '/'.$newSlideEntry); + $override->setAttribute('ContentType', self::SLIDE_CONTENT_TYPE); + $contentTypesDom->documentElement->appendChild($override); + + $relationship = $relsDom->createElementNS(self::NS_PACKAGE_RELATIONSHIPS, 'Relationship'); + $relationship->setAttribute('Id', $newRelId); + $relationship->setAttribute('Type', self::NS_RELATIONSHIPS.'/slide'); + $relationship->setAttribute('Target', 'slides/'.basename($newSlideEntry)); + $relsDom->documentElement->appendChild($relationship); + + $sldId = $presentationDom->createElementNS(self::NS_PRESENTATION, 'p:sldId'); + $sldId->setAttribute('id', $newSlideId); + $sldId->setAttributeNS(self::NS_RELATIONSHIPS, 'r:id', $newRelId); + + $insertAfter->parentNode->insertBefore($sldId, $insertAfter->nextSibling); + $insertAfter = $sldId; + + $newEntries[] = $newSlideEntry; + } + + $zip->addFromString('[Content_Types].xml', $contentTypesDom->saveXML()); + $zip->addFromString('ppt/_rels/presentation.xml.rels', $relsDom->saveXML()); + $zip->addFromString('ppt/presentation.xml', $presentationDom->saveXML()); + + return $newEntries; + } + + private function findSlideRelationshipId(DOMDocument $relsDom, string $slideEntry): ?string + { + $target = 'slides/'.basename($slideEntry); + + foreach ($relsDom->getElementsByTagName('Relationship') as $relationship) { + if ($relationship->getAttribute('Target') === $target) { + return $relationship->getAttribute('Id'); + } + } + + return null; + } + + private function maxRelationshipId(DOMDocument $relsDom): int + { + $max = 0; + + foreach ($relsDom->getElementsByTagName('Relationship') as $relationship) { + if (preg_match('/^rId(\d+)$/', $relationship->getAttribute('Id'), $matches)) { + $max = max($max, (int) $matches[1]); + } + } + + return $max; + } + + private function maxSlideId(DOMDocument $presentationDom): int + { + $max = 0; + + foreach ($presentationDom->getElementsByTagNameNS(self::NS_PRESENTATION, 'sldId') as $sldId) { + $max = max($max, (int) $sldId->getAttribute('id')); + } + + return $max; + } + + private function nextSlideNumber(ZipArchive $zip): int + { + $max = 0; + + foreach ($this->findSlideEntries($zip) as $entry) { + if (preg_match('#slide(\d+)\.xml$#', $entry, $matches)) { + $max = max($max, (int) $matches[1]); + } + } + + return $max + 1; + } + + private function loadXml(string $xml): ?DOMDocument + { + $dom = new DOMDocument; + $previousSetting = libxml_use_internal_errors(true); + $loaded = $dom->loadXML($xml); + libxml_clear_errors(); + libxml_use_internal_errors($previousSetting); + + return $loaded ? $dom : null; + } + /** * @return array */ @@ -126,13 +394,9 @@ class TemplateFillService $lookup[$this->normalizeKey($key)] = $value; } - $dom = new DOMDocument; - $previousSetting = libxml_use_internal_errors(true); - $loaded = $dom->loadXML($xml); - libxml_clear_errors(); - libxml_use_internal_errors($previousSetting); + $dom = $this->loadXml($xml); - if (! $loaded) { + if ($dom === null) { return null; } @@ -208,13 +472,9 @@ class TemplateFillService private function findImageRelationshipId(string $slideXml, string $shapeName): ?string { - $dom = new DOMDocument; - $previousSetting = libxml_use_internal_errors(true); - $loaded = $dom->loadXML($slideXml); - libxml_clear_errors(); - libxml_use_internal_errors($previousSetting); + $dom = $this->loadXml($slideXml); - if (! $loaded) { + if ($dom === null) { return null; } @@ -263,13 +523,9 @@ class TemplateFillService return null; } - $dom = new DOMDocument; - $previousSetting = libxml_use_internal_errors(true); - $loaded = $dom->loadXML($relsXml); - libxml_clear_errors(); - libxml_use_internal_errors($previousSetting); + $dom = $this->loadXml($relsXml); - if (! $loaded) { + if ($dom === null) { return null; } diff --git a/app/Services/WalletDepositService.php b/app/Services/WalletDepositService.php new file mode 100644 index 0000000..5f26223 --- /dev/null +++ b/app/Services/WalletDepositService.php @@ -0,0 +1,61 @@ +walletService->createForUser($user); + + $deposit = WalletDeposit::query()->create([ + 'wallet_id' => $wallet->id, + 'amount' => $amount, + 'status' => WalletDepositStatus::Pending, + 'gateway' => PaymentGatewayDriver::Fake->value, + ]); + + $paymentUrl = $this->gateway->initiate($deposit); + + return ['deposit' => $deposit, 'payment_url' => $paymentUrl]; + } + + /** + * @param array $callbackPayload + */ + public function handleCallback(WalletDeposit $deposit, array $callbackPayload): bool + { + if ($deposit->status !== WalletDepositStatus::Pending) { + return $deposit->status === WalletDepositStatus::Paid; + } + + if (! $this->gateway->verify($deposit, $callbackPayload)) { + $deposit->update(['status' => WalletDepositStatus::Failed]); + + return false; + } + + DB::transaction(function () use ($deposit): void { + $this->walletService->credit($deposit->wallet, $deposit->amount, 'شارژ کیف پول'); + + $deposit->update(['status' => WalletDepositStatus::Paid]); + }); + + return true; + } +} diff --git a/config/app.php b/config/app.php index c2b10e6..4912f98 100644 --- a/config/app.php +++ b/config/app.php @@ -54,6 +54,18 @@ return [ 'url' => env('APP_URL', 'http://localhost'), + /* + |-------------------------------------------------------------------------- + | Frontend URL + |-------------------------------------------------------------------------- + | + | Base URL of the frontend panel. Used to build redirect targets for flows + | that hand off to a browser mid-request, such as payment gateway callbacks. + | + */ + + 'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'), + /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/database/migrations/2026_08_28_160720_add_ai_fields_to_templates_table.php b/database/migrations/2026_08_28_160720_add_ai_fields_to_templates_table.php new file mode 100644 index 0000000..80df50f --- /dev/null +++ b/database/migrations/2026_08_28_160720_add_ai_fields_to_templates_table.php @@ -0,0 +1,29 @@ +boolean('is_ai_enabled')->default(false)->after('is_active'); + $table->unsignedInteger('ai_repeatable_slide_number')->nullable()->after('is_ai_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('templates', function (Blueprint $table) { + $table->dropColumn(['is_ai_enabled', 'ai_repeatable_slide_number']); + }); + } +}; diff --git a/database/migrations/2026_08_28_160721_add_ai_fields_to_template_generations_table.php b/database/migrations/2026_08_28_160721_add_ai_fields_to_template_generations_table.php new file mode 100644 index 0000000..8ba97b5 --- /dev/null +++ b/database/migrations/2026_08_28_160721_add_ai_fields_to_template_generations_table.php @@ -0,0 +1,32 @@ +string('source')->default('template')->after('template_id'); + $table->string('title')->nullable()->after('source'); + $table->string('language')->nullable()->after('title'); + $table->unsignedInteger('slide_count')->nullable()->after('language'); + $table->longText('source_text')->nullable()->after('slide_count'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('template_generations', function (Blueprint $table) { + $table->dropColumn(['source', 'title', 'language', 'slide_count', 'source_text']); + }); + } +}; diff --git a/database/migrations/2026_08_28_160721_add_avatar_and_public_profile_to_users_table.php b/database/migrations/2026_08_28_160721_add_avatar_and_public_profile_to_users_table.php new file mode 100644 index 0000000..797101e --- /dev/null +++ b/database/migrations/2026_08_28_160721_add_avatar_and_public_profile_to_users_table.php @@ -0,0 +1,29 @@ +string('avatar_path')->nullable()->after('mobile'); + $table->boolean('is_public')->default(false)->after('avatar_path'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['avatar_path', 'is_public']); + }); + } +}; diff --git a/database/migrations/2026_08_28_160721_add_is_repeatable_to_template_keys_table.php b/database/migrations/2026_08_28_160721_add_is_repeatable_to_template_keys_table.php new file mode 100644 index 0000000..64f18dd --- /dev/null +++ b/database/migrations/2026_08_28_160721_add_is_repeatable_to_template_keys_table.php @@ -0,0 +1,28 @@ +boolean('is_repeatable')->default(false)->after('is_required'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('template_keys', function (Blueprint $table) { + $table->dropColumn('is_repeatable'); + }); + } +}; diff --git a/database/migrations/2026_08_28_160721_create_wallet_deposits_table.php b/database/migrations/2026_08_28_160721_create_wallet_deposits_table.php new file mode 100644 index 0000000..8e662ef --- /dev/null +++ b/database/migrations/2026_08_28_160721_create_wallet_deposits_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('wallet_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('amount'); + $table->string('status')->default('pending'); + $table->string('gateway'); + $table->string('reference_id')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['wallet_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('wallet_deposits'); + } +}; diff --git a/database/migrations/2026_08_28_160722_create_notification_preferences_table.php b/database/migrations/2026_08_28_160722_create_notification_preferences_table.php new file mode 100644 index 0000000..2aca136 --- /dev/null +++ b/database/migrations/2026_08_28_160722_create_notification_preferences_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->boolean('comment_on_presentation')->default(true); + $table->boolean('form_reply')->default(false); + $table->boolean('new_follower')->default(false); + $table->boolean('news_announcements')->default(false); + $table->boolean('weekly_product_updates')->default(true); + $table->boolean('weekly_blog_digest')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notification_preferences'); + } +}; diff --git a/routes/api.php b/routes/api.php index e8d12fd..6d9bd2e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Api\ProfileController; use App\Http\Controllers\Api\TemplateController; use App\Http\Controllers\Api\TemplateGenerationController; use App\Http\Controllers\Api\WalletController; +use App\Http\Controllers\Api\WalletDepositController; use Illuminate\Support\Facades\Route; Route::prefix('auth')->group(function (): void { @@ -22,24 +23,33 @@ Route::prefix('templates')->group(function (): void { Route::middleware('auth:sanctum')->group(function (): void { Route::post('{template:slug}/generate', [TemplateGenerationController::class, 'store']); + Route::post('{template:slug}/generate-from-text', [TemplateGenerationController::class, 'storeFromText']); }); }); +Route::get('wallet/deposits/{deposit}/callback', [WalletDepositController::class, 'callback'])->name('api.wallet.deposits.callback'); + Route::middleware('auth:sanctum')->group(function (): void { Route::get('profile', [ProfileController::class, 'show']); Route::put('profile', [ProfileController::class, 'update']); + Route::put('profile/password', [ProfileController::class, 'changePassword']); + Route::delete('profile', [ProfileController::class, 'destroy']); Route::prefix('wallet')->group(function (): void { Route::get('/', [WalletController::class, 'show']); Route::get('transactions', [WalletController::class, 'transactions']); + Route::post('deposits', [WalletDepositController::class, 'store']); }); Route::prefix('notifications')->group(function (): void { Route::get('/', [NotificationController::class, 'index']); Route::post('read-all', [NotificationController::class, 'markAllAsRead']); + Route::get('preferences', [NotificationController::class, 'preferences']); + Route::put('preferences', [NotificationController::class, 'updatePreferences']); }); Route::prefix('generations')->group(function (): void { + Route::get('/', [TemplateGenerationController::class, 'index']); Route::get('{generation:uuid}', [TemplateGenerationController::class, 'show']); Route::get('{generation:uuid}/download', [TemplateGenerationController::class, 'download'])->name('api.generations.download'); });