diff --git a/app/Enums/NotificationType.php b/app/Enums/NotificationType.php new file mode 100644 index 0000000..712d163 --- /dev/null +++ b/app/Enums/NotificationType.php @@ -0,0 +1,19 @@ + 'سیستمی', + self::Wallet => 'کیف پول', + self::Template => 'قالب', + }; + } +} diff --git a/app/Enums/TemplateGenerationStatus.php b/app/Enums/TemplateGenerationStatus.php new file mode 100644 index 0000000..960381b --- /dev/null +++ b/app/Enums/TemplateGenerationStatus.php @@ -0,0 +1,21 @@ + 'در انتظار', + self::Processing => 'در حال تولید', + self::Completed => 'تکمیل شده', + self::Failed => 'ناموفق', + }; + } +} diff --git a/app/Enums/TemplateKeyType.php b/app/Enums/TemplateKeyType.php new file mode 100644 index 0000000..044ae29 --- /dev/null +++ b/app/Enums/TemplateKeyType.php @@ -0,0 +1,19 @@ + 'متن', + self::Image => 'عکس', + self::Chart => 'چارت', + }; + } +} diff --git a/app/Filament/Resources/Categories/CategoryResource.php b/app/Filament/Resources/Categories/CategoryResource.php new file mode 100644 index 0000000..d4a88d9 --- /dev/null +++ b/app/Filament/Resources/Categories/CategoryResource.php @@ -0,0 +1,54 @@ + ListCategories::route('/'), + 'create' => CreateCategory::route('/create'), + 'edit' => EditCategory::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Categories/Pages/CreateCategory.php b/app/Filament/Resources/Categories/Pages/CreateCategory.php new file mode 100644 index 0000000..a842af9 --- /dev/null +++ b/app/Filament/Resources/Categories/Pages/CreateCategory.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->label('نام') + ->required() + ->maxLength(255), + TextInput::make('slug') + ->label('شناسه') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + Textarea::make('description') + ->label('توضیحات') + ->maxLength(1000) + ->rows(3), + Toggle::make('is_active') + ->label('فعال') + ->default(true), + ]); + } +} diff --git a/app/Filament/Resources/Categories/Tables/CategoriesTable.php b/app/Filament/Resources/Categories/Tables/CategoriesTable.php new file mode 100644 index 0000000..673953d --- /dev/null +++ b/app/Filament/Resources/Categories/Tables/CategoriesTable.php @@ -0,0 +1,55 @@ +columns([ + TextColumn::make('name') + ->label('نام') + ->searchable() + ->sortable(), + TextColumn::make('slug') + ->label('شناسه') + ->searchable() + ->sortable(), + TextColumn::make('templates_count') + ->label('تعداد قالب‌ها') + ->counts('templates'), + IconColumn::make('is_active') + ->label('فعال') + ->boolean(), + TextColumn::make('created_at') + ->label('تاریخ ایجاد') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TernaryFilter::make('is_active') + ->label('وضعیت') + ->placeholder('همه') + ->trueLabel('فعال') + ->falseLabel('غیرفعال'), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Notifications/NotificationResource.php b/app/Filament/Resources/Notifications/NotificationResource.php new file mode 100644 index 0000000..43f6ad7 --- /dev/null +++ b/app/Filament/Resources/Notifications/NotificationResource.php @@ -0,0 +1,56 @@ + ListNotifications::route('/'), + 'create' => CreateNotification::route('/create'), + 'view' => ViewNotification::route('/{record}'), + 'edit' => EditNotification::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Notifications/Pages/CreateNotification.php b/app/Filament/Resources/Notifications/Pages/CreateNotification.php new file mode 100644 index 0000000..f0248d3 --- /dev/null +++ b/app/Filament/Resources/Notifications/Pages/CreateNotification.php @@ -0,0 +1,11 @@ +components([ + Select::make('user_id') + ->label('کاربر') + ->relationship('user', 'name') + ->searchable() + ->required(), + Select::make('type') + ->label('نوع') + ->options(collect(NotificationType::cases())->mapWithKeys(fn (NotificationType $type) => [$type->value => $type->label()])) + ->required(), + TextInput::make('title') + ->label('عنوان') + ->required() + ->maxLength(255), + Textarea::make('body') + ->label('متن') + ->required() + ->rows(4), + ]); + } +} diff --git a/app/Filament/Resources/Notifications/Tables/NotificationsTable.php b/app/Filament/Resources/Notifications/Tables/NotificationsTable.php new file mode 100644 index 0000000..eeab885 --- /dev/null +++ b/app/Filament/Resources/Notifications/Tables/NotificationsTable.php @@ -0,0 +1,69 @@ +columns([ + TextColumn::make('user.name') + ->label('کاربر') + ->searchable() + ->sortable(), + TextColumn::make('type') + ->label('نوع') + ->badge() + ->formatStateUsing(fn (NotificationType $state): string => $state->label()), + TextColumn::make('title') + ->label('عنوان') + ->searchable() + ->limit(40), + IconColumn::make('read_at') + ->label('خوانده شده') + ->boolean() + ->getStateUsing(fn ($record): bool => $record->read_at !== null), + TextColumn::make('created_at') + ->label('تاریخ ایجاد') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('type') + ->label('نوع') + ->options(collect(NotificationType::cases())->mapWithKeys(fn (NotificationType $type) => [$type->value => $type->label()])), + TernaryFilter::make('read_at') + ->label('وضعیت') + ->placeholder('همه') + ->trueLabel('خوانده شده') + ->falseLabel('خوانده نشده') + ->queries( + true: fn ($query) => $query->whereNotNull('read_at'), + false: fn ($query) => $query->whereNull('read_at'), + ), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/TemplateGenerations/Pages/ListTemplateGenerations.php b/app/Filament/Resources/TemplateGenerations/Pages/ListTemplateGenerations.php new file mode 100644 index 0000000..b1b9ec3 --- /dev/null +++ b/app/Filament/Resources/TemplateGenerations/Pages/ListTemplateGenerations.php @@ -0,0 +1,11 @@ +columns([ + TextColumn::make('uuid') + ->label('شناسه') + ->searchable() + ->copyable(), + TextColumn::make('template.title') + ->label('قالب') + ->searchable() + ->sortable(), + TextColumn::make('status') + ->label('وضعیت') + ->badge() + ->formatStateUsing(fn (TemplateGenerationStatus $state): string => $state->label()) + ->color(fn (TemplateGenerationStatus $state): string => match ($state) { + TemplateGenerationStatus::Pending => 'gray', + TemplateGenerationStatus::Processing => 'warning', + TemplateGenerationStatus::Completed => 'success', + TemplateGenerationStatus::Failed => 'danger', + }), + TextColumn::make('requester.name') + ->label('درخواست‌دهنده') + ->searchable() + ->placeholder('—'), + TextColumn::make('error_message') + ->label('خطا') + ->limit(50) + ->placeholder('—'), + TextColumn::make('created_at') + ->label('تاریخ') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->label('وضعیت') + ->options([ + TemplateGenerationStatus::Pending->value => TemplateGenerationStatus::Pending->label(), + TemplateGenerationStatus::Processing->value => TemplateGenerationStatus::Processing->label(), + TemplateGenerationStatus::Completed->value => TemplateGenerationStatus::Completed->label(), + TemplateGenerationStatus::Failed->value => TemplateGenerationStatus::Failed->label(), + ]), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app/Filament/Resources/TemplateGenerations/TemplateGenerationResource.php b/app/Filament/Resources/TemplateGenerations/TemplateGenerationResource.php new file mode 100644 index 0000000..b62a782 --- /dev/null +++ b/app/Filament/Resources/TemplateGenerations/TemplateGenerationResource.php @@ -0,0 +1,60 @@ + ListTemplateGenerations::route('/'), + 'view' => ViewTemplateGeneration::route('/{record}'), + ]; + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit($record): bool + { + return false; + } + + public static function canDelete($record): bool + { + return false; + } +} diff --git a/app/Filament/Resources/Templates/Pages/CreateTemplate.php b/app/Filament/Resources/Templates/Pages/CreateTemplate.php new file mode 100644 index 0000000..9900737 --- /dev/null +++ b/app/Filament/Resources/Templates/Pages/CreateTemplate.php @@ -0,0 +1,22 @@ + $data + * @return array + */ + protected function mutateFormDataBeforeCreate(array $data): array + { + $data['created_by'] = auth()->id(); + + return $data; + } +} diff --git a/app/Filament/Resources/Templates/Pages/EditTemplate.php b/app/Filament/Resources/Templates/Pages/EditTemplate.php new file mode 100644 index 0000000..cf52dcd --- /dev/null +++ b/app/Filament/Resources/Templates/Pages/EditTemplate.php @@ -0,0 +1,21 @@ +recordTitleAttribute('uuid') + ->columns([ + TextColumn::make('uuid') + ->label('شناسه') + ->searchable() + ->copyable(), + TextColumn::make('status') + ->label('وضعیت') + ->badge() + ->formatStateUsing(fn (TemplateGenerationStatus $state): string => $state->label()) + ->color(fn (TemplateGenerationStatus $state): string => match ($state) { + TemplateGenerationStatus::Pending => 'gray', + TemplateGenerationStatus::Processing => 'warning', + TemplateGenerationStatus::Completed => 'success', + TemplateGenerationStatus::Failed => 'danger', + }), + TextColumn::make('requester.name') + ->label('درخواست‌دهنده') + ->placeholder('—'), + TextColumn::make('error_message') + ->label('خطا') + ->limit(40) + ->placeholder('—'), + TextColumn::make('created_at') + ->label('تاریخ') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->label('وضعیت') + ->options([ + TemplateGenerationStatus::Pending->value => TemplateGenerationStatus::Pending->label(), + TemplateGenerationStatus::Processing->value => TemplateGenerationStatus::Processing->label(), + TemplateGenerationStatus::Completed->value => TemplateGenerationStatus::Completed->label(), + TemplateGenerationStatus::Failed->value => TemplateGenerationStatus::Failed->label(), + ]), + ]) + ->recordActions([]) + ->toolbarActions([]); + } +} diff --git a/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php b/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php new file mode 100644 index 0000000..6d18a62 --- /dev/null +++ b/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php @@ -0,0 +1,106 @@ +components([ + TextInput::make('key') + ->label('کلید') + ->helperText('در فایل پاورپوینت برای متن به‌صورت {{key}} و برای عکس/چارت به‌صورت نام شکل (shape name) استفاده می‌شود.') + ->required() + ->maxLength(255), + TextInput::make('label') + ->label('برچسب') + ->required() + ->maxLength(255), + Select::make('type') + ->label('نوع') + ->options([ + TemplateKeyType::Text->value => TemplateKeyType::Text->label(), + TemplateKeyType::Image->value => TemplateKeyType::Image->label(), + TemplateKeyType::Chart->value => TemplateKeyType::Chart->label(), + ]) + ->required() + ->live(), + TextInput::make('shape_name') + ->label('نام شکل (Shape Name)') + ->helperText('نام شکل تعیین‌شده در Selection Pane نرم‌افزار PowerPoint') + ->visible(fn (Get $get): bool => $get('type') !== TemplateKeyType::Text->value) + ->maxLength(255), + TextInput::make('description') + ->label('راهنما') + ->helperText('توضیحی که برای مصرف‌کننده API نمایش داده می‌شود') + ->maxLength(255), + Toggle::make('is_required') + ->label('اجباری') + ->default(true), + TextInput::make('sort_order') + ->label('ترتیب نمایش') + ->numeric() + ->integer() + ->default(0), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('key') + ->columns([ + TextColumn::make('key') + ->label('کلید') + ->searchable() + ->sortable(), + TextColumn::make('label') + ->label('برچسب') + ->searchable(), + TextColumn::make('type') + ->label('نوع') + ->badge() + ->formatStateUsing(fn (TemplateKeyType $state): string => $state->label()), + TextColumn::make('shape_name') + ->label('نام شکل') + ->placeholder('—'), + IconColumn::make('is_required') + ->label('اجباری') + ->boolean(), + TextColumn::make('sort_order') + ->label('ترتیب') + ->sortable(), + ]) + ->defaultSort('sort_order') + ->headerActions([ + CreateAction::make(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]); + } +} diff --git a/app/Filament/Resources/Templates/Schemas/TemplateForm.php b/app/Filament/Resources/Templates/Schemas/TemplateForm.php new file mode 100644 index 0000000..3e044ab --- /dev/null +++ b/app/Filament/Resources/Templates/Schemas/TemplateForm.php @@ -0,0 +1,54 @@ +components([ + Select::make('category_id') + ->label('دسته‌بندی') + ->options(fn (): array => Category::query()->pluck('name', 'id')->all()) + ->searchable() + ->required(), + TextInput::make('title') + ->label('عنوان') + ->required() + ->maxLength(255), + TextInput::make('slug') + ->label('شناسه') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + Textarea::make('description') + ->label('توضیحات') + ->maxLength(1000) + ->rows(3), + FileUpload::make('file_path') + ->label('فایل پاورپوینت') + ->disk('local') + ->directory('templates/sources') + ->acceptedFileTypes(['application/vnd.openxmlformats-officedocument.presentationml.presentation']) + ->required() + ->visibility('private'), + FileUpload::make('thumbnail_path') + ->label('تصویر بندانگشتی') + ->disk('public') + ->directory('templates/thumbnails') + ->image(), + Toggle::make('is_active') + ->label('فعال') + ->default(true), + ]); + } +} diff --git a/app/Filament/Resources/Templates/Tables/TemplatesTable.php b/app/Filament/Resources/Templates/Tables/TemplatesTable.php new file mode 100644 index 0000000..03f7a68 --- /dev/null +++ b/app/Filament/Resources/Templates/Tables/TemplatesTable.php @@ -0,0 +1,61 @@ +columns([ + TextColumn::make('title') + ->label('عنوان') + ->searchable() + ->sortable(), + TextColumn::make('category.name') + ->label('دسته‌بندی') + ->searchable() + ->sortable(), + TextColumn::make('keys_count') + ->label('تعداد کلیدها') + ->counts('keys'), + IconColumn::make('is_active') + ->label('فعال') + ->boolean(), + TextColumn::make('created_at') + ->label('تاریخ ایجاد') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('category_id') + ->label('دسته‌بندی') + ->relationship('category', 'name'), + TernaryFilter::make('is_active') + ->label('وضعیت') + ->placeholder('همه') + ->trueLabel('فعال') + ->falseLabel('غیرفعال'), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Templates/TemplateResource.php b/app/Filament/Resources/Templates/TemplateResource.php new file mode 100644 index 0000000..45d4176 --- /dev/null +++ b/app/Filament/Resources/Templates/TemplateResource.php @@ -0,0 +1,66 @@ + ListTemplates::route('/'), + 'create' => CreateTemplate::route('/create'), + 'view' => ViewTemplate::route('/{record}'), + 'edit' => EditTemplate::route('/{record}/edit'), + ]; + } +} diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php index 65e5b70..13a963f 100644 --- a/app/Http/Controllers/Api/AuthController.php +++ b/app/Http/Controllers/Api/AuthController.php @@ -7,6 +7,7 @@ use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\RegisterRequest; use App\Http\Resources\UserResource; use App\Models\User; +use App\Traits\ApiResponse; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -14,6 +15,8 @@ use OpenApi\Attributes as OA; class AuthController extends Controller { + use ApiResponse; + #[OA\Post( path: '/auth/register', description: 'Register a new user account', @@ -64,12 +67,9 @@ class AuthController extends Controller $token = $user->createToken('auth-token')->plainTextToken; - return response()->json([ - 'message' => 'ثبت‌نام با موفقیت انجام شد.', - 'data' => [ - 'user' => new UserResource($user), - 'token' => $token, - ], + return $this->success('ثبت‌نام با موفقیت انجام شد.', [ + 'user' => new UserResource($user), + 'token' => $token, ], 201); } @@ -121,22 +121,16 @@ class AuthController extends Controller ->first(); if (! $user || ! Hash::check($request->validated('password'), $user->password)) { - return response()->json([ - 'message' => 'اطلاعات ورود نادرست است.', - 'errors' => [ - 'login' => ['ایمیل یا نام کاربری یا رمز عبور اشتباه است.'], - ], - ], 422); + return $this->error('اطلاعات ورود نادرست است.', [ + 'login' => ['ایمیل یا نام کاربری یا رمز عبور اشتباه است.'], + ]); } $token = $user->createToken('auth-token')->plainTextToken; - return response()->json([ - 'message' => 'ورود با موفقیت انجام شد.', - 'data' => [ - 'user' => new UserResource($user), - 'token' => $token, - ], + return $this->success('ورود با موفقیت انجام شد.', [ + 'user' => new UserResource($user), + 'token' => $token, ]); } @@ -147,7 +141,7 @@ class AuthController extends Controller $counter = 1; while (User::query()->where('username', $username)->exists()) { - $username = $base.$counter; + $username = $base . $counter; $counter++; } diff --git a/app/Http/Controllers/Api/CategoryController.php b/app/Http/Controllers/Api/CategoryController.php new file mode 100644 index 0000000..b99ea9b --- /dev/null +++ b/app/Http/Controllers/Api/CategoryController.php @@ -0,0 +1,52 @@ +where('is_active', true)->orderBy('name')->get(); + + return $this->success('دسته‌بندی‌ها با موفقیت دریافت شد.', [ + 'categories' => CategoryResource::collection($categories), + ]); + } +} diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php new file mode 100644 index 0000000..d5aed1b --- /dev/null +++ b/app/Http/Controllers/Api/NotificationController.php @@ -0,0 +1,89 @@ + []]], + tags: ['Notifications'], + 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: 'Notifications 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: 'notifications', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Notification') + ), + ], + 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); + + $notifications = $request->user()->notifications()->paginate($perPage); + + return $this->success('نوتیفیکیشن‌ها با موفقیت دریافت شد.', [ + 'notifications' => NotificationResource::collection($notifications)->response()->getData(true), + ]); + } + + #[OA\Post( + path: '/notifications/read-all', + description: 'Mark all the authenticated user notifications as read', + summary: 'Mark all notifications as read', + security: [['sanctum' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response( + response: 200, + description: 'Notifications marked as read 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 markAllAsRead(Request $request): JsonResponse + { + $this->notificationService->markAllAsRead($request->user()); + + return $this->success('همه نوتیفیکیشن‌ها خوانده شد.'); + } +} diff --git a/app/Http/Controllers/Api/ProfileController.php b/app/Http/Controllers/Api/ProfileController.php new file mode 100644 index 0000000..c1f8fe8 --- /dev/null +++ b/app/Http/Controllers/Api/ProfileController.php @@ -0,0 +1,98 @@ + []]], + tags: ['Profile'], + responses: [ + new OA\Response( + response: 200, + description: 'Profile 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: 'user', ref: '#/components/schemas/User'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + ] + )] + public function show(): JsonResponse + { + return $this->success('اطلاعات پروفایل با موفقیت دریافت شد.', [ + 'user' => new UserResource(request()->user()), + ]); + } + + #[OA\Put( + path: '/profile', + description: 'Update the authenticated user profile', + summary: 'Update profile', + security: [['sanctum' => []]], + tags: ['Profile'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', example: 'علی رضایی'), + 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: 'password', type: 'string', format: 'password', example: 'password123'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Profile 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: 'user', ref: '#/components/schemas/User'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] + public function update(UpdateProfileRequest $request): JsonResponse + { + $user = $request->user(); + + $user->update($request->validated()); + + return $this->success('پروفایل با موفقیت به‌روزرسانی شد.', [ + 'user' => new UserResource($user->fresh()), + ]); + } +} diff --git a/app/Http/Controllers/Api/TemplateController.php b/app/Http/Controllers/Api/TemplateController.php new file mode 100644 index 0000000..54fd68a --- /dev/null +++ b/app/Http/Controllers/Api/TemplateController.php @@ -0,0 +1,104 @@ +with('category') + ->where('is_active', true) + ->when( + $request->string('category')->isNotEmpty(), + fn ($query) => $query->whereHas( + 'category', + fn ($categoryQuery) => $categoryQuery->where('slug', $request->string('category')) + ) + ) + ->orderBy('title') + ->get(); + + return $this->success('قالب‌ها با موفقیت دریافت شد.', [ + 'templates' => TemplateResource::collection($templates), + ]); + } + + #[OA\Get( + path: '/templates/{slug}', + description: 'Get a template details including its fillable keys', + summary: 'Show template', + tags: ['Templates'], + parameters: [ + new OA\Parameter(name: 'slug', description: 'Template slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Template 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: 'template', ref: '#/components/schemas/Template'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 404, description: 'Template not found'), + ] + )] + public function show(Template $template): JsonResponse + { + $template->load('category', 'keys'); + + return $this->success('قالب با موفقیت دریافت شد.', [ + 'template' => new TemplateResource($template), + ]); + } +} diff --git a/app/Http/Controllers/Api/TemplateGenerationController.php b/app/Http/Controllers/Api/TemplateGenerationController.php new file mode 100644 index 0000000..4499778 --- /dev/null +++ b/app/Http/Controllers/Api/TemplateGenerationController.php @@ -0,0 +1,155 @@ + []]], + tags: ['Templates'], + parameters: [ + new OA\Parameter(name: 'slug', description: 'Template slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + 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 store(GenerateTemplateRequest $request, Template $template): JsonResponse + { + $uuid = (string) Str::uuid(); + $values = $request->validated('values', []); + + $storedValues = []; + + foreach ($template->keys as $key) { + if (! array_key_exists($key->key, $values)) { + continue; + } + + if ($key->type === TemplateKeyType::Image) { + $storedValues[$key->key] = $request->file('values.'.$key->key) + ->store('templates/generations/inputs/'.$uuid, 'local'); + + continue; + } + + $storedValues[$key->key] = $values[$key->key]; + } + + $generation = TemplateGeneration::query()->create([ + 'template_id' => $template->id, + 'uuid' => $uuid, + 'status' => TemplateGenerationStatus::Pending, + 'values' => $storedValues, + 'requested_by' => $request->user()->id, + ]); + + GenerateTemplateJob::dispatch($generation); + + return $this->success('درخواست تولید پاورپوینت ثبت شد.', [ + 'generation' => new TemplateGenerationResource($generation), + ], 202); + } + + #[OA\Get( + path: '/generations/{uuid}', + description: 'Get the status of a presentation generation request', + summary: 'Show generation status', + security: [['sanctum' => []]], + tags: ['Templates'], + parameters: [ + new OA\Parameter(name: 'uuid', description: 'Generation uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Generation status 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: 'generation', ref: '#/components/schemas/TemplateGeneration'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] + public function show(TemplateGeneration $generation): JsonResponse + { + abort_if($generation->requested_by !== request()->user()->id, 403); + + return $this->success('وضعیت تولید با موفقیت دریافت شد.', [ + 'generation' => new TemplateGenerationResource($generation), + ]); + } + + #[OA\Get( + path: '/generations/{uuid}/download', + description: 'Download the generated presentation file', + summary: 'Download generated presentation', + security: [['sanctum' => []]], + tags: ['Templates'], + parameters: [ + new OA\Parameter(name: 'uuid', description: 'Generation uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'File download'), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Not found or not ready'), + ] + )] + public function download(TemplateGeneration $generation): StreamedResponse + { + abort_if($generation->requested_by !== request()->user()->id, Response::HTTP_FORBIDDEN); + abort_if($generation->status !== TemplateGenerationStatus::Completed || ! $generation->output_path, Response::HTTP_NOT_FOUND); + + return Storage::disk('local')->download($generation->output_path, $generation->template->slug.'.pptx'); + } +} diff --git a/app/Http/Controllers/Api/WalletController.php b/app/Http/Controllers/Api/WalletController.php new file mode 100644 index 0000000..06df7f6 --- /dev/null +++ b/app/Http/Controllers/Api/WalletController.php @@ -0,0 +1,100 @@ + []]], + tags: ['Wallet'], + responses: [ + new OA\Response( + response: 200, + description: 'Wallet 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: 'wallet', ref: '#/components/schemas/Wallet'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + ] + )] + public function show(Request $request): JsonResponse + { + $wallet = $this->walletService->createForUser($request->user()); + + return $this->success('اطلاعات کیف پول با موفقیت دریافت شد.', [ + 'wallet' => new WalletResource($wallet), + ]); + } + + #[OA\Get( + path: '/wallet/transactions', + description: 'Get the authenticated user wallet transactions', + summary: 'List wallet transactions', + security: [['sanctum' => []]], + tags: ['Wallet'], + 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: 'Wallet transactions 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: 'transactions', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/WalletTransaction') + ), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + ] + )] + public function transactions(Request $request): JsonResponse + { + $wallet = $this->walletService->createForUser($request->user()); + + $perPage = min(max((int) $request->integer('per_page', 15), 1), 100); + + $transactions = $wallet->transactions()->paginate($perPage); + + return $this->success('تراکنش‌های کیف پول با موفقیت دریافت شد.', [ + 'transactions' => WalletTransactionResource::collection($transactions)->response()->getData(true), + ]); + } +} diff --git a/app/Http/Requests/Api/GenerateTemplateRequest.php b/app/Http/Requests/Api/GenerateTemplateRequest.php new file mode 100644 index 0000000..39a8e6f --- /dev/null +++ b/app/Http/Requests/Api/GenerateTemplateRequest.php @@ -0,0 +1,50 @@ + + */ + public function rules(): array + { + /** @var Template $template */ + $template = $this->route('template'); + + $rules = []; + + foreach ($template->keys as $key) { + $fieldRules = [$key->is_required ? 'required' : 'nullable']; + + $fieldRules[] = match ($key->type) { + TemplateKeyType::Text => 'string', + TemplateKeyType::Image => 'file|image', + TemplateKeyType::Chart => 'prohibited', + }; + + $rules['values.'.$key->key] = $fieldRules; + } + + return $rules; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + '*.prohibited' => 'کلید :attribute از نوع چارت است و فعلاً پشتیبانی نمی‌شود.', + ]; + } +} diff --git a/app/Http/Requests/Profile/UpdateProfileRequest.php b/app/Http/Requests/Profile/UpdateProfileRequest.php new file mode 100644 index 0000000..85ec27b --- /dev/null +++ b/app/Http/Requests/Profile/UpdateProfileRequest.php @@ -0,0 +1,50 @@ + + */ + public function rules(): array + { + $userId = $this->user()->id; + + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'username' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('users', 'username')->ignore($userId)], + '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'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => 'نام و نام خانوادگی الزامی است.', + 'username.required' => 'نام کاربری الزامی است.', + 'username.unique' => 'این نام کاربری قبلاً استفاده شده است.', + 'email.required' => 'ایمیل الزامی است.', + 'email.email' => 'فرمت ایمیل صحیح نیست.', + 'email.unique' => 'این ایمیل قبلاً ثبت شده است.', + 'mobile.required' => 'شماره موبایل الزامی است.', + 'mobile.regex' => 'فرمت شماره موبایل صحیح نیست.', + 'mobile.unique' => 'این شماره موبایل قبلاً ثبت شده است.', + 'password.required' => 'رمز عبور الزامی است.', + 'password.min' => 'رمز عبور باید حداقل ۸ کاراکتر باشد.', + ]; + } +} diff --git a/app/Http/Resources/CategoryResource.php b/app/Http/Resources/CategoryResource.php new file mode 100644 index 0000000..aaa6a77 --- /dev/null +++ b/app/Http/Resources/CategoryResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + ]; + } +} diff --git a/app/Http/Resources/NotificationResource.php b/app/Http/Resources/NotificationResource.php new file mode 100644 index 0000000..8b8b5e7 --- /dev/null +++ b/app/Http/Resources/NotificationResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type->value, + 'type_label' => $this->type->label(), + 'title' => $this->title, + 'body' => $this->body, + 'data' => $this->data, + 'read_at' => $this->read_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/TemplateGenerationResource.php b/app/Http/Resources/TemplateGenerationResource.php new file mode 100644 index 0000000..a4fbb62 --- /dev/null +++ b/app/Http/Resources/TemplateGenerationResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'uuid' => $this->uuid, + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'error_message' => $this->error_message, + 'download_url' => $this->output_path ? route('api.generations.download', $this->uuid) : null, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/TemplateKeyResource.php b/app/Http/Resources/TemplateKeyResource.php new file mode 100644 index 0000000..667c318 --- /dev/null +++ b/app/Http/Resources/TemplateKeyResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'key' => $this->key, + 'label' => $this->label, + 'type' => $this->type->value, + 'type_label' => $this->type->label(), + 'is_required' => $this->is_required, + 'description' => $this->description, + ]; + } +} diff --git a/app/Http/Resources/TemplateResource.php b/app/Http/Resources/TemplateResource.php new file mode 100644 index 0000000..7152475 --- /dev/null +++ b/app/Http/Resources/TemplateResource.php @@ -0,0 +1,44 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'slug' => $this->slug, + 'description' => $this->description, + 'thumbnail_url' => $this->thumbnail_path ? asset('storage/'.$this->thumbnail_path) : null, + 'category' => new CategoryResource($this->whenLoaded('category')), + 'keys' => TemplateKeyResource::collection($this->whenLoaded('keys')), + ]; + } +} diff --git a/app/Http/Resources/WalletResource.php b/app/Http/Resources/WalletResource.php new file mode 100644 index 0000000..7156466 --- /dev/null +++ b/app/Http/Resources/WalletResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'balance' => $this->balance, + 'is_active' => $this->is_active, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/WalletTransactionResource.php b/app/Http/Resources/WalletTransactionResource.php new file mode 100644 index 0000000..4b1abc6 --- /dev/null +++ b/app/Http/Resources/WalletTransactionResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type->value, + 'type_label' => $this->type->label(), + 'amount' => $this->amount, + 'balance_before' => $this->balance_before, + 'balance_after' => $this->balance_after, + 'description' => $this->description, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/GenerateTemplateJob.php b/app/Jobs/GenerateTemplateJob.php new file mode 100644 index 0000000..de9293c --- /dev/null +++ b/app/Jobs/GenerateTemplateJob.php @@ -0,0 +1,39 @@ +generation->update(['status' => TemplateGenerationStatus::Processing]); + + try { + $outputPath = $templateFillService->fill($this->generation); + + $this->generation->update([ + 'status' => TemplateGenerationStatus::Completed, + 'output_path' => $outputPath, + ]); + } catch (Throwable $exception) { + $this->generation->update([ + 'status' => TemplateGenerationStatus::Failed, + 'error_message' => $exception->getMessage(), + ]); + } + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..d6463ae --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,29 @@ + + */ + public function templates(): HasMany + { + return $this->hasMany(Template::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..fa0efb7 --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,37 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isRead(): bool + { + return $this->read_at !== null; + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => NotificationType::class, + 'data' => 'array', + 'read_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Template.php b/app/Models/Template.php new file mode 100644 index 0000000..f60efb2 --- /dev/null +++ b/app/Models/Template.php @@ -0,0 +1,63 @@ + + */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + /** + * @return BelongsTo + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * @return HasMany + */ + public function keys(): HasMany + { + return $this->hasMany(TemplateKey::class)->orderBy('sort_order'); + } + + /** + * @return HasMany + */ + public function generations(): HasMany + { + return $this->hasMany(TemplateGeneration::class)->latest(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Models/TemplateGeneration.php b/app/Models/TemplateGeneration.php new file mode 100644 index 0000000..8bd8870 --- /dev/null +++ b/app/Models/TemplateGeneration.php @@ -0,0 +1,47 @@ + + */ + public function template(): BelongsTo + { + return $this->belongsTo(Template::class); + } + + /** + * @return BelongsTo + */ + public function requester(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_by'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => TemplateGenerationStatus::class, + 'values' => 'array', + ]; + } +} diff --git a/app/Models/TemplateKey.php b/app/Models/TemplateKey.php new file mode 100644 index 0000000..70b2535 --- /dev/null +++ b/app/Models/TemplateKey.php @@ -0,0 +1,41 @@ + + */ + public function template(): BelongsTo + { + return $this->belongsTo(Template::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => TemplateKeyType::class, + 'is_required' => 'boolean', + 'sort_order' => 'integer', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 317b174..dd2a38b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -47,6 +48,14 @@ class User extends Authenticatable implements FilamentUser return $this->hasManyThrough(WalletTransaction::class, Wallet::class); } + /** + * @return HasMany + */ + public function notifications(): HasMany + { + return $this->hasMany(Notification::class)->latest(); + } + public function hasRole(string|array $roles): bool { $roles = (array) $roles; diff --git a/app/Policies/CategoryPolicy.php b/app/Policies/CategoryPolicy.php new file mode 100644 index 0000000..650a087 --- /dev/null +++ b/app/Policies/CategoryPolicy.php @@ -0,0 +1,39 @@ +canManage($user); + } + + public function view(User $user, Category $category): bool + { + return $this->canManage($user); + } + + public function create(User $user): bool + { + return $this->canManage($user); + } + + public function update(User $user, Category $category): bool + { + return $this->canManage($user); + } + + public function delete(User $user, Category $category): bool + { + return $this->canManage($user); + } + + private function canManage(User $user): bool + { + return $user->hasRole('admin') || $user->hasPermission('templates.manage'); + } +} diff --git a/app/Policies/NotificationPolicy.php b/app/Policies/NotificationPolicy.php new file mode 100644 index 0000000..9707429 --- /dev/null +++ b/app/Policies/NotificationPolicy.php @@ -0,0 +1,34 @@ +id === $notification->user_id || $this->canManage($user); + } + + public function update(User $user, Notification $notification): bool + { + return $this->canManage($user); + } + + public function delete(User $user, Notification $notification): bool + { + return $this->canManage($user); + } + + private function canManage(User $user): bool + { + return $user->hasRole('admin') || $user->hasPermission('notifications.manage'); + } +} diff --git a/app/Policies/TemplatePolicy.php b/app/Policies/TemplatePolicy.php new file mode 100644 index 0000000..1d4073e --- /dev/null +++ b/app/Policies/TemplatePolicy.php @@ -0,0 +1,39 @@ +canManage($user); + } + + public function view(User $user, Template $template): bool + { + return $this->canManage($user); + } + + public function create(User $user): bool + { + return $this->canManage($user); + } + + public function update(User $user, Template $template): bool + { + return $this->canManage($user); + } + + public function delete(User $user, Template $template): bool + { + return $this->canManage($user); + } + + private function canManage(User $user): bool + { + return $user->hasRole('admin') || $user->hasPermission('templates.manage'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6d0511d..63679b6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,12 +2,14 @@ namespace App\Providers; +use App\Models\Notification; use App\Models\Permission; use App\Models\Role; use App\Models\User; use App\Models\Wallet; use App\Models\WalletTransaction; use App\Observers\UserObserver; +use App\Policies\NotificationPolicy; use App\Policies\PermissionPolicy; use App\Policies\RolePolicy; use App\Policies\UserPolicy; @@ -43,5 +45,6 @@ class AppServiceProvider extends ServiceProvider Gate::policy(Permission::class, PermissionPolicy::class); Gate::policy(Wallet::class, WalletPolicy::class); Gate::policy(WalletTransaction::class, WalletTransactionPolicy::class); + Gate::policy(Notification::class, NotificationPolicy::class); } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 0000000..33bf1eb --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,28 @@ +|null $data + */ + public function send(User $user, NotificationType $type, string $title, string $body, ?array $data = null): Notification + { + return $user->notifications()->create([ + 'type' => $type, + 'title' => $title, + 'body' => $body, + 'data' => $data, + ]); + } + + public function markAllAsRead(User $user): void + { + $user->notifications()->whereNull('read_at')->update(['read_at' => now()]); + } +} diff --git a/app/Services/TemplateFillService.php b/app/Services/TemplateFillService.php new file mode 100644 index 0000000..522e4de --- /dev/null +++ b/app/Services/TemplateFillService.php @@ -0,0 +1,104 @@ +template; + + $sourcePath = Storage::disk('local')->path($template->file_path); + $presentation = IOFactory::load($sourcePath); + + $values = $generation->values ?? []; + + foreach ($template->keys as $key) { + if (! array_key_exists($key->key, $values)) { + continue; + } + + match ($key->type) { + TemplateKeyType::Text => $this->fillText($presentation, $key->key, (string) $values[$key->key]), + TemplateKeyType::Image => $this->fillImage($presentation, $key->shape_name, Storage::disk('local')->path($values[$key->key])), + TemplateKeyType::Chart => throw new RuntimeException('پرکردن کلید از نوع چارت هنوز پشتیبانی نمی‌شود.'), + }; + } + + $outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx'; + $outputAbsolutePath = Storage::disk('local')->path($outputRelativePath); + + Storage::disk('local')->makeDirectory('templates/generations'); + + $writer = IOFactory::createWriter($presentation, 'PowerPoint2007'); + $writer->save($outputAbsolutePath); + + return $outputRelativePath; + } + + private function fillText(PhpPresentation $presentation, string $key, string $value): void + { + $placeholder = '{{'.$key.'}}'; + + foreach ($presentation->getAllSlides() as $slide) { + foreach ($slide->getShapeCollection() as $shape) { + if (! $shape instanceof RichText) { + continue; + } + + foreach ($shape->getParagraphs() as $paragraph) { + foreach ($paragraph->getRichTextElements() as $element) { + if (str_contains($element->getText(), $placeholder)) { + $element->setText(str_replace($placeholder, $value, $element->getText())); + } + } + } + } + } + } + + private function fillImage(PhpPresentation $presentation, ?string $shapeName, string $imagePath): void + { + if ($shapeName === null || $shapeName === '') { + return; + } + + foreach ($presentation->getAllSlides() as $slide) { + foreach ($slide->getShapeCollection() as $shape) { + if ($shape->getName() !== $shapeName) { + continue; + } + + if ($shape instanceof GdDrawing) { + $this->replaceGdImage($shape, $imagePath); + } elseif ($shape instanceof FileDrawing) { + $shape->setPath($imagePath); + } + } + } + } + + private function replaceGdImage(GdDrawing $shape, string $imagePath): void + { + $info = getimagesize($imagePath); + $image = imagecreatefromstring(file_get_contents($imagePath)); + + if ($info === false || $image === false) { + throw new RuntimeException('فایل تصویر ارسالی معتبر نیست.'); + } + + $shape->setImageResource($image); + $shape->setMimeType($info['mime']); + $shape->setRenderingFunction(str_replace('/', '', $info['mime'])); + } +} diff --git a/app/Traits/ApiResponse.php b/app/Traits/ApiResponse.php new file mode 100644 index 0000000..0f773b2 --- /dev/null +++ b/app/Traits/ApiResponse.php @@ -0,0 +1,24 @@ +json([ + 'message' => $message, + 'data' => $data, + ], $status); + } + + protected function error(string $message, array $errors = [], int $status = 422): JsonResponse + { + return response()->json([ + 'message' => $message, + 'errors' => $errors, + ], $status); + } +} diff --git a/composer.json b/composer.json index a407477..bca2134 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "filament/filament": "^4.0", "laravel/framework": "^13.8", "laravel/sanctum": "^4.3", - "laravel/tinker": "^3.0" + "laravel/tinker": "^3.0", + "phpoffice/phppresentation": "^1.2" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 6245a25..cb0f62b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c479d2908801be9c7f146d6a1c4662b8", + "content-hash": "228c4071ac2afaa94e16b892dc1fbdc4", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -447,6 +447,85 @@ ], "time": "2026-03-20T21:10:52+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, { "name": "danharrin/date-format-converter", "version": "v0.3.1", @@ -3386,6 +3465,191 @@ ], "time": "2026-04-30T23:56:43+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2026-04-11T18:38:28+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -4133,6 +4397,279 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "pclzip/pclzip", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/ivanlanin/pclzip.git", + "reference": "19dd1de9d3f5fc4d7d70175b4c344dee329f45fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ivanlanin/pclzip/zipball/19dd1de9d3f5fc4d7d70175b4c344dee329f45fd", + "reference": "19dd1de9d3f5fc4d7d70175b4c344dee329f45fd", + "shasum": "" + }, + "type": "library", + "autoload": { + "classmap": [ + "pclzip.lib.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "Vincent Blavet" + } + ], + "description": "A PHP library that offers compression and extraction functions for Zip formatted archives", + "homepage": "http://www.phpconcept.net/pclzip", + "keywords": [ + "php", + "zip" + ], + "support": { + "issues": "https://github.com/ivanlanin/pclzip/issues", + "source": "https://github.com/ivanlanin/pclzip/tree/master" + }, + "time": "2014-06-05T11:42:24+00:00" + }, + { + "name": "phpoffice/common", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/Common.git", + "reference": "5a2eeb82d4dfce4ce2163819063ba6f5a80c3e91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/Common/zipball/5a2eeb82d4dfce4ce2163819063ba6f5a80c3e91", + "reference": "5a2eeb82d4dfce4ce2163819063ba6f5a80c3e91", + "shasum": "" + }, + "require": { + "pclzip/pclzip": "^2.8", + "php": ">=7.1" + }, + "require-dev": { + "phpmd/phpmd": "2.*", + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpunit/phpunit": ">=7" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\Common\\": "src/Common/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-only" + ], + "authors": [ + { + "name": "Mark Baker" + }, + { + "name": "Franck Lefevre", + "homepage": "http://rootslabs.net" + } + ], + "description": "PHPOffice Common", + "homepage": "http://phpoffice.github.io", + "keywords": [ + "common", + "component", + "office", + "php" + ], + "support": { + "issues": "https://github.com/PHPOffice/Common/issues", + "source": "https://github.com/PHPOffice/Common/tree/1.0.5" + }, + "time": "2025-02-28T12:39:45+00:00" + }, + { + "name": "phpoffice/phppresentation", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PHPPresentation.git", + "reference": "b9cdcd413e2d463ba3198f912f32716adc88f340" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PHPPresentation/zipball/b9cdcd413e2d463ba3198f912f32716adc88f340", + "reference": "b9cdcd413e2d463ba3198f912f32716adc88f340", + "shasum": "" + }, + "require": { + "ext-xml": "*", + "ext-zip": "*", + "php": "^7.1|^8.0", + "phpoffice/common": "^1", + "phpoffice/phpspreadsheet": "^1.9 || ^2.0 || ^3.0 || ^4.0" + }, + "require-dev": { + "dompdf/dompdf": "^3.1", + "phpmd/phpmd": "2.*", + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpunit/phpunit": ">=7.0" + }, + "suggest": { + "ext-gd": "Required to add images" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpPresentation\\": "src/PhpPresentation/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-only" + ], + "authors": [ + { + "name": "Mark Baker" + }, + { + "name": "Franck Lefevre", + "homepage": "http://rootslabs.net" + }, + { + "name": "Ivan Lanin", + "homepage": "http://ivan.lanin.org" + } + ], + "description": "PHPPresentation - Read, Create and Write Presentations documents in PHP", + "homepage": "http://phpoffice.github.io", + "keywords": [ + "LibreOffice", + "PowerPoint", + "odp", + "php", + "ppt", + "pptx", + "presentations" + ], + "support": { + "issues": "https://github.com/PHPOffice/PHPPresentation/issues", + "source": "https://github.com/PHPOffice/PHPPresentation/tree/1.2.0" + }, + "time": "2025-06-06T10:27:25+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2ea9786632e6fac1aee601b6e426bcc723d8ce13", + "reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.1", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/4.5.0" + }, + "time": "2025-07-24T05:15:59+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -8687,85 +9224,6 @@ ], "time": "2026-03-29T15:46:14+00:00" }, - { - "name": "composer/pcre", - "version": "3.3.2", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-11-12T16:29:46+00:00" - }, { "name": "composer/xdebug-handler", "version": "3.0.5", diff --git a/database/migrations/2026_07_06_094558_create_categories_table.php b/database/migrations/2026_07_06_094558_create_categories_table.php new file mode 100644 index 0000000..f8f465a --- /dev/null +++ b/database/migrations/2026_07_06_094558_create_categories_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2026_07_06_094558_create_templates_table.php b/database/migrations/2026_07_06_094558_create_templates_table.php new file mode 100644 index 0000000..212a621 --- /dev/null +++ b/database/migrations/2026_07_06_094558_create_templates_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('category_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('file_path'); + $table->string('thumbnail_path')->nullable(); + $table->boolean('is_active')->default(true); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('templates'); + } +}; diff --git a/database/migrations/2026_07_06_094559_create_template_generations_table.php b/database/migrations/2026_07_06_094559_create_template_generations_table.php new file mode 100644 index 0000000..3a9926b --- /dev/null +++ b/database/migrations/2026_07_06_094559_create_template_generations_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('template_id')->constrained()->cascadeOnDelete(); + $table->uuid('uuid')->unique(); + $table->string('status')->default('pending'); + $table->json('values')->nullable(); + $table->string('output_path')->nullable(); + $table->string('error_message')->nullable(); + $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['template_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('template_generations'); + } +}; diff --git a/database/migrations/2026_07_06_094559_create_template_keys_table.php b/database/migrations/2026_07_06_094559_create_template_keys_table.php new file mode 100644 index 0000000..31698b6 --- /dev/null +++ b/database/migrations/2026_07_06_094559_create_template_keys_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('template_id')->constrained()->cascadeOnDelete(); + $table->string('key'); + $table->string('label'); + $table->string('type'); + $table->string('shape_name')->nullable(); + $table->boolean('is_required')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->string('description')->nullable(); + $table->timestamps(); + + $table->unique(['template_id', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('template_keys'); + } +}; diff --git a/database/migrations/2026_07_06_150000_create_notifications_table.php b/database/migrations/2026_07_06_150000_create_notifications_table.php new file mode 100644 index 0000000..373c096 --- /dev/null +++ b/database/migrations/2026_07_06_150000_create_notifications_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('title'); + $table->text('body'); + $table->json('data')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + $table->index(['user_id', 'read_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/routes/api.php b/routes/api.php index e995a77..e8d12fd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,46 @@ group(function (): void { Route::post('register', [AuthController::class, 'register']); Route::post('login', [AuthController::class, 'login']); }); + +Route::get('categories', [CategoryController::class, 'index']); + +Route::prefix('templates')->group(function (): void { + Route::get('/', [TemplateController::class, 'index']); + Route::get('{template:slug}', [TemplateController::class, 'show']); + + Route::middleware('auth:sanctum')->group(function (): void { + Route::post('{template:slug}/generate', [TemplateGenerationController::class, 'store']); + }); +}); + +Route::middleware('auth:sanctum')->group(function (): void { + Route::get('profile', [ProfileController::class, 'show']); + Route::put('profile', [ProfileController::class, 'update']); + + Route::prefix('wallet')->group(function (): void { + Route::get('/', [WalletController::class, 'show']); + Route::get('transactions', [WalletController::class, 'transactions']); + }); + + Route::prefix('notifications')->group(function (): void { + Route::get('/', [NotificationController::class, 'index']); + Route::post('read-all', [NotificationController::class, 'markAllAsRead']); + }); + + Route::prefix('generations')->group(function (): void { + Route::get('{generation:uuid}', [TemplateGenerationController::class, 'show']); + Route::get('{generation:uuid}/download', [TemplateGenerationController::class, 'download'])->name('api.generations.download'); + }); +}); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index a6f6395..1e6f929 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -157,6 +157,231 @@ } } } + }, + "/profile": { + "get": { + "tags": [ + "Profile" + ], + "summary": "Show profile", + "description": "Get the authenticated user profile", + "operationId": "c6a79007a56511faa6f48397a2d15867", + "responses": { + "200": { + "description": "Profile fetched successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "اطلاعات پروفایل با موفقیت دریافت شد." + }, + "data": { + "properties": { + "user": { + "$ref": "#/components/schemas/User" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthenticated" + } + }, + "security": [ + { + "sanctum": [] + } + ] + }, + "put": { + "tags": [ + "Profile" + ], + "summary": "Update profile", + "description": "Update the authenticated user profile", + "operationId": "74599296b1816be41ad4228975229966", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "example": "علی رضایی" + }, + "username": { + "type": "string", + "example": "ali" + }, + "email": { + "type": "string", + "format": "email", + "example": "ali@example.com" + }, + "mobile": { + "type": "string", + "example": "09123456789" + }, + "password": { + "type": "string", + "format": "password", + "example": "password123" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Profile updated successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "پروفایل با موفقیت به‌روزرسانی شد." + }, + "data": { + "properties": { + "user": { + "$ref": "#/components/schemas/User" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthenticated" + }, + "422": { + "description": "Validation error" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/wallet": { + "get": { + "tags": [ + "Wallet" + ], + "summary": "Show wallet", + "description": "Get the authenticated user wallet", + "operationId": "4f659600ab1fd5e3ab27775fea03ec91", + "responses": { + "200": { + "description": "Wallet fetched successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "اطلاعات کیف پول با موفقیت دریافت شد." + }, + "data": { + "properties": { + "wallet": { + "$ref": "#/components/schemas/Wallet" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthenticated" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/wallet/transactions": { + "get": { + "tags": [ + "Wallet" + ], + "summary": "List wallet transactions", + "description": "Get the authenticated user wallet transactions", + "operationId": "3ea6e910cbeabfdd2ca4db13b8d358b2", + "parameters": [ + { + "name": "per_page", + "in": "query", + "description": "Items per page", + "schema": { + "type": "integer", + "example": 15 + } + } + ], + "responses": { + "200": { + "description": "Wallet transactions fetched successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "تراکنش‌های کیف پول با موفقیت دریافت شد." + }, + "data": { + "properties": { + "transactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WalletTransaction" + } + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthenticated" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } } }, "components": { @@ -190,6 +415,65 @@ } }, "type": "object" + }, + "Wallet": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "balance": { + "type": "integer", + "example": 500000 + }, + "is_active": { + "type": "boolean", + "example": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, + "WalletTransaction": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "example": "credit" + }, + "type_label": { + "type": "string", + "example": "واریز" + }, + "amount": { + "type": "integer", + "example": 100000 + }, + "balance_before": { + "type": "integer", + "example": 400000 + }, + "balance_after": { + "type": "integer", + "example": 500000 + }, + "description": { + "type": "string", + "example": "شارژ کیف پول", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" } } }, @@ -197,6 +481,14 @@ { "name": "Auth", "description": "Authentication endpoints" + }, + { + "name": "Profile", + "description": "Profile" + }, + { + "name": "Wallet", + "description": "Wallet" } ] } \ No newline at end of file