diff --git a/FRONTOFFICE-CMS-API-COMPATIBILITY.md b/FRONTOFFICE-CMS-API-COMPATIBILITY.md deleted file mode 100644 index f31cdcb..0000000 --- a/FRONTOFFICE-CMS-API-COMPATIBILITY.md +++ /dev/null @@ -1,619 +0,0 @@ -# FrontOffice to CMS API Compatibility Analysis - -**تاریخ:** 6 فوریه 2026 -**وضعیت:** در حال بررسی - -## خلاصه اجرایی - -این سند مقایسه API‌های مورد نیاز FrontOffice با API‌های موجود در CMS را نشان می‌دهد. - ---- - -## 1. User APIs (Authentication & Profile) - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetUser()` | AuthService, Personal.razor | ✅ موجود | `GetUser(GetUserRequest)` | -| `UpdateUser()` | Personal.razor | ✅ موجود | `UpdateUser(UpdateUserRequest)` | -| `RefreshToken()` | AuthService | ✅ موجود | `RefreshToken(RefreshTokenRequest)` | -| `CreateNewOtpToken()` | AuthDialog | ✅ موجود | `CreateNewOtpToken(CreateNewOtpTokenRequest)` | -| `VerifyOtpToken()` | AuthDialog | ✅ موجود | `VerifyOtpToken(VerifyOtpTokenRequest)` | -| `AcceptContract()` | RegisterWizard | ✅ موجود | `AcceptContract(AcceptContractRequest)` | -| `GetCustomerProfile()` | Profile Pages | ✅ موجود | **پیاده شد در Task قبل** | -| `GetCustomerReferrals()` | Tree.razor | ✅ موجود | **پیاده شد در Task قبل** | -| `GetCustomerSettings()` | Settings.razor | ✅ موجود | **پیاده شد در Task قبل** | -| `UpdateCustomerProfile()` | Personal.razor | ✅ موجود | Proto موجود است | -| `ChangeCustomerPassword()` | ChangePassword.razor | ✅ موجود | Proto موجود است | -| `UpdateCustomerSettings()` | Settings.razor | ✅ موجود | Proto موجود است | - -**نتیجه:** ✅ تمام User APIs موجود است - ---- - -## 2. Products APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetCustomerProducts()` | ProductService | ✅ موجود | **پیاده شد در Task قبل** | -| `GetCustomerProductsByFilter()` | ProductService | ✅ موجود | **پیاده شد در Task قبل** | -| `GetAllProductsByFilter()` | Products.razor | ✅ پیاده شد | **Public API - Feb 6, 2026** | - -**GetAllProductsByFilter Details:** -- از `GetCustomerProductsByFilterQuery` استفاده می‌کند -- پشتیبانی از فیلترها: Title, Price, Discount, CategoryId, SaleCount, و... -- Sorting: پشتیبانی کامل (مثلاً "price desc") -- Pagination: با MetaData کامل -- CategoryIds: لیست شناسه دسته‌بندی‌های محصول - -**نتیجه:** ✅ تمام Products APIs موجود و پیاده شده - ---- - -## 3. Category APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetAllCategoriesForCustomer()` | CategoryService | ✅ پیاده شد | **Customer API - Feb 6, 2026** | -| `GetCategoryById()` | CategoryService | ✅ موجود | Admin API: `GetCategory()` | - -**GetAllCategoriesForCustomer Details:** -- از `GetAllCategoryByFilterQuery` استفاده می‌کند -- فقط دسته‌بندی‌های فعال (IsActive = true) -- مرتب‌سازی بر اساس SortOrder -- پشتیبانی Pagination (default: PageSize=100) -- شامل: Id, Name, Title, Description, ImagePath, ParentId, IsActive, SortOrder -- ISender به CategoryService اضافه شد - -**نتیجه:** ✅ تمام Category APIs پیاده شده - ---- - -## 4. UserOrder APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetAllUserOrderByFilter()` | OrderService, Orders.razor | ✅ پیاده شد | **Feb 6, 2026** - Admin API | -| `GetUserOrder()` | OrderService, OrderDetail.razor | ✅ پیاده شد | **Feb 6, 2026** - جزئیات کامل سفارش | -| `GetCustomerOrders()` | OrderService | ✅ موجود | Customer API با فیلتر UserId | -| `GetCustomerOrder()` | OrderService | ✅ موجود | Customer API با فیلتر UserId | -| `GetUserOrderHistory()` | OrderService | ✅ موجود | Proto: `GetCustomerOrderHistory()` | -| `GetVATRate()` | VATService, OrderService | ✅ پیاده شد | **Feb 6, 2026** | -| `SubmitShopBuyOrder()` | CheckoutSummary.razor | ✅ پیاده شد | **Feb 6, 2026** - تکمیل فرآیند خرید | - -**GetVATRate Details:** -- نرخ مالیات بر ارزش افزوده ایران: 9% -- `VatRate = 0.09` (decimal) -- `VatPercentage = 9` (int) -- `IsEnabled = true` -- استفاده در VATService برای محاسبه مالیات محصولات - -**SubmitShopBuyOrder Details (Feb 6, 2026 - Updated with Wallet Payment):** -تبدیل سبد خرید به سفارش نهایی با پرداخت از کیف پول: - -1. **احراز هویت**: استخراج UserId از JWT Token (ICurrentUserService) -2. **اعتبارسنجی سبد خرید**: - - بازیابی محصولات سبد خرید با Include(Product) - - چک کردن خالی نبودن سبد -3. **اعتبارسنجی آدرس**: - - دریافت آدرس پیش‌فرض کاربر - - اجباری بودن وجود آدرس -4. **محاسبات مالی**: - - مبلغ پایه: جمع (قیمت × تعداد) تمام آیتم‌ها - - مالیات: 9% از مبلغ پایه - - مبلغ کل: مبلغ پایه + مالیات - - اعتبارسنجی مبلغ: |serverTotal - clientTotal| < 100 -5. **اعتبارسنجی کیف پول (New - Feb 6)**: - - بازیابی کیف پول کاربر (UserWallet) - - چک موجودی: Balance >= TotalAmount - - خطا در صورت کمبود موجودی با نمایش موجودی فعلی و مبلغ مورد نیاز -6. **ایجاد تراکنش (New - Feb 6)**: - - Type: TransactionType.Buy (0) - - Amount: TotalAmount - - PaymentStatus: Success - - PaymentDate: DateTime.UtcNow - - RefId: SHOP_{timestamp} - - Description: "خرید محصولات - سفارش #{OrderId}" -7. **کسر از کیف پول (New - Feb 6)**: - - Balance -= TotalAmount - - ثبت موجودی جدید در UserWallet -8. **لاگ تغییرات کیف پول (New - Feb 6)**: - - CurrentBalance: موجودی جدید - - ChangeValue: -TotalAmount (منفی برای برداشت) - - CurrentNetworkBalance: بدون تغییر - - CurrentDiscountBalance: بدون تغییر - - IsIncrease: false (برداشت) - - RefrenceId: TransactionId -9. **ایجاد سفارش (UserOrder) - Updated**: - - TransactionId: لینک به تراکنش (New) - - PaymentStatus: Success (Changed from Pending) - - PaymentDate: DateTime.UtcNow (New) - - PaymentMethod: Wallet (New) - - DeliveryStatus: Pending - - HasVAT: true -10. **ثبت مالیات (OrderVAT)**: - - VATRate: 0.09m (decimal) - - BaseAmount: مبلغ قبل از مالیات - - VATAmount: مبلغ مالیات - - TotalAmount: مبلغ کل -11. **جزئیات فاکتور (FactorDetails)**: - - یک رکورد برای هر آیتم سبد خرید - - ذخیره ProductId, Count, UnitPrice, UnitDiscountPrice -12. **پاکسازی سبد خرید**: - - Soft delete تمام آیتم‌های سبد (IsDeleted = true) - -**Transaction Flow:** -``` -User → Cart → SubmitShopBuyOrder → -1. Validate Cart -2. Validate Address -3. Calculate Amount (Base + 9% VAT) -4. Validate Wallet Balance -5. Create Transaction (Type=Buy, Status=Success) -6. Deduct from Wallet.Balance -7. Create UserWalletChangeLog (audit trail) -8. Create Order (linked to Transaction, PaymentStatus=Success, PaymentMethod=Wallet) -9. Create OrderVAT -10. Create FactorDetails -11. Clear Cart -→ Return OrderId -``` - -**Wallet Types:** -- **Balance** (موجودی عادی): Used for purchases - deducted in this flow -- **NetworkBalance** (موجودی شبکه): Commission wallet - not touched -- **DiscountBalance** (موجودی تخفیف): Discount-only wallet - not touched - -**Error Handling:** -- "کیف پول یافت نشد": User has no wallet record -- "موجودی کیف پول کافی نیست. موجودی: X تومان، مورد نیاز: Y تومان": Insufficient funds - -**خروجی**: شناسه سفارش (OrderId) برای redirect به صفحه جزئیات - -**GetUserOrder Details (Feb 6, 2026):** -نمایش جزئیات کامل یک سفارش: -- اطلاعات سفارش: Id, Amount, PaymentStatus, PaymentDate, DeliveryStatus -- اطلاعات کاربر: UserFullName, UserNationalCode -- آدرس: UserAddressText -- مالیات (OrderVAT): VATRate, BaseAmount, VATAmount, TotalAmount, IsPaid -- ردیابی: TrackingCode, DeliveryDescription -- محصولات (FactorDetails): ProductId, ProductTitle, ProductThumbnailPath, UnitPrice, Count, UnitDiscountPrice - -**اصلاحات صفحه OrderDetail.razor:** -- ✅ رفع NullReferenceException برای PaymentDate -- ✅ نمایش "تاریخ ثبت" برای سفارشات Pending (بدون PaymentDate) -- ✅ رفع نمایش اشتباه ProductThumbnailPath به جای ProductTitle -- ✅ رفع خطاهای nullable value access (.Value → ?? 0) -- ✅ محاسبه صحیح subtotal با nullable handling - -**GetAllUserOrderByFilter Details (Feb 6, 2026):** -لیست تمام سفارشات با فیلترهای پیشرفته: -- فیلترها: UserId (optional - 0 = همه کاربران), PaymentStatus, DeliveryStatus, PaymentDate -- Pagination: MetaData کامل -- Sorting: بر اساس فیلدهای مختلف -- جزئیات هر سفارش: اطلاعات کاربر، آدرس، مالیات، محصولات، وضعیت ارسال - -**نتیجه:** ✅ تمام UserOrder APIs پیاده شده - فرآیند خرید کامل است - ---- - -## 5. UserWallet APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetCustomerWallet()` | WalletService | ✅ موجود | 3 نوع کیف پول: Balance, NetworkBalance, DiscountBalance | -| `GetCustomerWalletChangeLog()` | WalletService | ✅ موجود | 6 فیلد موجودی: Current+Change برای هر 3 کیف پول | -| `CustomerWithdrawBalance()` | WalletService | ✅ موجود | Proto موجود است | -| `GetCustomerWithdrawals()` | WithdrawalRequests.razor | ✅ موجود | لیست درخواست‌های برداشت | -| `GetCustomerWithdrawalSettings()` | WalletService | ✅ موجود | حداقل مبلغ برداشت | - -**سه نوع کیف پول:** -1. **عادی (Regular)**: Balance & ChangeValue - برای خرید و شارژ عادی -2. **شبکه (Network)**: NetworkBalance & ChangeNerworkValue - پاداش تیمی و کمیسیون -3. **تخفیفی (Discount)**: DiscountBalance & ChangeDiscountValue - برای خرید تخفیفی - -**ساختار تراکنش (CustomerWalletChangeLogModel):** -- `CurrentBalance` + `ChangeValue` - موجودی و تغییر کیف پول عادی -- `CurrentNetworkBalance` + `ChangeNerworkValue` - موجودی و تغییر کیف پول شبکه -- `CurrentDiscountBalance` + `ChangeDiscountValue` - موجودی و تغییر کیف پول تخفیفی -- `IsIncrease` - آیا افزایش است یا کاهش -- `RefrenceId` - شناسه ارجاع (سفارش، پرداخت، و...) -- `CreatedAt` - تاریخ تراکنش (UTC Timestamp) - -**UI تراکنش‌ها:** -- Desktop: جدول با ستون‌های جداگانه برای هر 3 کیف پول (تغییرات/مانده) -- Mobile: کارت‌ها با 3 باکس افقی (عادی آبی، شبکه سبز، تخفیفی زرد) -- تاریخ: تبدیل UTC به Local Time و نمایش جلالی -- توضیحات: نمایش اینکه کدام کیف پول‌ها تغییر کرده‌اند - -**نتیجه:** ✅ تمام UserWallet APIs موجود و پیاده شده با UI کامل (Feb 5, 2026) - ---- - -## 6. Transaction APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetCustomerTransaction()` | TransactionService (در BFF) | ✅ موجود | **پیاده شد در Task قبل** | -| `GetCustomerTransactionsByFilter()` | TransactionService | ✅ موجود | **پیاده شد در Task قبل** | -| `CustomerPaymentRequest()` | Checkout workflow | ✅ موجود | Proto موجود است | -| `CustomerPaymentVerification()` | PaymentCallback.razor | ✅ موجود | Proto موجود است | - -**نتیجه:** ✅ تمام Transaction APIs موجود است - ---- - -## 7. UserCarts APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetCustomerCart()` | CartService | ✅ پیاده شد | **Query Handler تکمیل شد - Feb 5** | -| `AddToCustomerCart()` | CartService | ✅ پیاده شد | **Command Handler تکمیل شد - Feb 5** | -| `UpdateCustomerCartItem()` | CartService | ✅ پیاده شد | **Command Handler تکمیل شد - Feb 5** | -| `RemoveFromCustomerCart()` | CartService | ✅ پیاده شد | **Command Handler تکمیل شد - Feb 5** | - -**اصلاحات Feb 6, 2026:** -- ✅ **رفع باگ Cart APIs در CheckoutSummary**: تمام صفحات از Admin APIs استفاده می‌کردند -- ✅ تغییر `AddNewUserCartAsync` → `AddNewUserCartForCustomerAsync` -- ✅ تغییر `UpdateUserCartAsync` → `UpdateUserCartForCustomerAsync` -- ✅ تغییر request model: `AddNewUserCartRequest` → `AddNewUserCartForCustomerRequest` -- ✅ تغییر request model: `UpdateUserCartRequest` → `UpdateUserCartForCustomerRequest` -- ✅ اضافه `RemoveUserCartForCustomerAsync` برای حذف صحیح آیتم -- ✅ اصلاح field name: `UserCartId` → `CartItemId` (Proto: cart_item_id) -- ✅ رفع منطق حذف: از Update با Count=0 به RemoveUserCartForCustomer تغییر یافت - -**Field Naming Convention:** -- Proto: `cart_item_id` (snake_case) -- C# Generated: `CartItemId` (PascalCase) -- ❌ نباید: `UserCartId` (نام قدیمی Admin API) - -**تاثیر:** حالا عملیات سبد خرید (افزودن/ویرایش/حذف) صحیح کار می‌کند و فقط سبد کاربر جاری را تغییر می‌دهد - -**نتیجه:** ✅ تمام UserCart Customer APIs پیاده شده و باگ‌های Security و Field Naming رفع شد - ---- - -## 8. UserAddress APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetCustomerAddresses()` | Addresses.razor | ✅ پیاده شد | **Query Handler تکمیل شد - Feb 5** | -| `CreateCustomerAddress()` | AddAddressDialog.razor | ✅ پیاده شد | **Command Handler تکمیل شد - Feb 5** | -| `UpdateCustomerAddress()` | EditAddressDialog.razor | ✅ پیاده شد | **Command Handler تکمیل شد - Feb 5** | -| `DeleteCustomerAddress()` | Addresses.razor | ✅ پیاده شد | **Command Handler تکمیل شد - Feb 5** | -| `SetCustomerDefaultAddress()` | Addresses.razor | ✅ پیاده شد | **Command Handler تکمیل شد - Feb 5** | - -**یادداشت:** CityName و ProvinceName در response خالی است - FrontOffice باید از City API جداگانه استفاده کند. - -**اصلاحات Feb 6, 2026:** -- ✅ **رفع باگ صفحه Addresses**: تمام صفحات FrontOffice از Admin APIs استفاده می‌کردند -- ✅ تغییر `GetAllUserAddressByFilter` → `GetCustomerAddresses` در Addresses.razor -- ✅ تغییر `CreateNewUserAddress` → `CreateCustomerAddress` در AddAddressDialog -- ✅ تغییر `UpdateUserAddress` → `UpdateCustomerAddress` در EditAddressDialog -- ✅ تغییر `DeleteUserAddress` → `DeleteCustomerAddress` در Addresses.razor -- ✅ تغییر `SetAddressAsDefault` → `SetCustomerDefaultAddress` در Addresses.razor -- ✅ اصلاح Model type: `GetAllUserAddressByFilterResponseModel` → `CustomerAddressModel` -- ✅ اصلاح field name: `response.Addresses` → `response.Models` - -**تاثیر:** حالا کاربران فقط آدرس‌های خودشان را می‌بینند (قبلاً همه آدرس‌ها نمایش داده می‌شد) - -**نتیجه:** ✅ تمام UserAddress Customer APIs پیاده شده و باگ Security رفع شد - ---- - -## 9. City APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetAllCities()` | AddressDialog components | ✅ موجود | Public API | - -**نتیجه:** ✅ City APIs موجود است - ---- - -## 10. Package APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetCustomerPackages()` | PackageService | ✅ موجود | **پیاده شد در Task قبل** | -| `GetCustomerPackageDetails()` | PackageService | ✅ موجود | **پیاده شد در Task قبل** | -| `CustomerPurchasePackage()` | Package purchase flow | ✅ موجود | Proto موجود است | -| `CustomerVerifyPackagePurchase()` | Package verification | ✅ موجود | Proto موجود است | -| `GetCustomerPurchaseHistory()` | MyPackages.razor | ✅ موجود | **پیاده شد در Task قبل** | - -**نتیجه:** ✅ تمام Package APIs موجود است - ---- - -## 11. NetworkMembership APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetMyNetworkTree()` | NetworkMembershipService | ✅ موجود | Customer Query جداگانه با ICurrentUserService | -| `GetSubordinateTree()` | NetworkMembershipService | ✅ موجود | Recursive tree traversal | -| `GetMyNetworkStatistics()` | NetworkStatisticsPage.razor | ✅ موجود | با شمارش recursive تمام descendants | - -**اصلاحات انجام شده (Feb 5, 2026):** -1. ✅ **GetMyNetworkTree Customer Query**: - - ایجاد Query و Handler جداگانه برای Customer - - استفاده از ICurrentUserService به جای UserId در request - - رفع خطای Validation (UserId=0 قبلاً غیرمجاز بود) - -2. ✅ **GetNetworkStatistics Bug Fix**: - - قبلاً: فقط direct children (depth=1) شمارش می‌شد - - بعد: recursive counting تمام descendants در leftLeg و rightLeg - - متدهای کمکی: `GetAllDescendants()` و `CalculateDepths()` - - فرمول: `leftLegCount = GetAllDescendants(leftChild).Count + 1` - -**نتیجه:** ✅ تمام NetworkMembership APIs موجود و اصلاح شده - ---- - -## 12. Commission APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetWeekDefinitions()` | CommissionService | ✅ موجود | **پیاده شد در Task قبل** | -| `GetCommissionBalances()` | CommissionDashboardPage | ✅ موجود | **پیاده شد در Task قبل** | - -**نتیجه:** ✅ تمام Commission APIs موجود است - ---- - -## 13. ClubMembership APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `ActivateClubMembership()` | ClubMembershipService | ✅ موجود | Proto موجود در CMS | -| `GetClubMembershipStatus()` | MembershipPage.razor | ✅ موجود | Proto موجود در CMS | - -**نتیجه:** ✅ ClubMembership APIs موجود است - ---- - -## 14. Configuration APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetClubConfiguration()` | ClubConfigurationService | ✅ موجود | Proto موجود در CMS | -| `GetClubFeatures()` | FeaturesPage.razor | ✅ موجود | Proto موجود در CMS | - -**نتیجه:** ✅ Configuration APIs موجود است - ---- - -## 15. AppVersion APIs - -### استفاده شده در FrontOffice - -| API Method | استفاده در Service | Status در CMS | یادداشت | -|------------|-------------------|---------------|---------| -| `GetAppVersion()` | AppVersionService | ✅ موجود | Proto موجود در CMS | - -**نتیجه:** ✅ AppVersion APIs موجود است - ---- - -## نتیجه‌گیری کلی - -### ✅ API های کامل (100% پیاده شده) -1. ✅ User APIs - همه Customer endpoints پیاده شده -2. ✅ Products APIs - GetCustomerProducts و Filter پیاده شده -3. ✅ UserWallet APIs - تمام Customer endpoints پیاده شده -4. ✅ Transaction APIs - Customer endpoints پیاده شده -5. ✅ Package APIs - تمام Customer endpoints پیاده شده -6. ✅ NetworkMembership APIs - پیاده شده -7. ✅ Commission APIs - پیاده شده -8. ✅ Category APIs - GetAllCategoriesForCustomer پیاده شد (Feb 6, 2026) -9. ✅ City APIs - Public API موجود -10. ✅ ClubMembership APIs - Proto موجود -11. ✅ Configuration APIs - Proto موجود -12. ✅ AppVersion APIs - Proto موجود -13. ✅ **UserCarts APIs - تمام Customer endpoints پیاده شد (Feb 5, 2026) + اصلاحات Feb 6** 🆕 -14. ✅ **UserAddress APIs - تمام Customer endpoints پیاده شد (Feb 5, 2026) + باگ Security رفع شد Feb 6** 🆕 -15. ✅ **UserOrder APIs - Checkout workflow کامل شد (Feb 6, 2026)** 🆕 -16. ✅ **Products APIs - GetAllProductsByFilter پیاده شد (Feb 6, 2026)** 🆕 - -### ⚠️ نیاز به توجه - -~~1. **UserCarts APIs** - نیاز به Customer-specific endpoints~~ - **✅ تکمیل شد - Feb 5, 2026 + اصلاحات Feb 6, 2026** - -~~2. **UserAddress APIs** - نیاز به Customer-specific endpoints~~ - **✅ تکمیل شد - Feb 5, 2026 + باگ Security رفع شد Feb 6, 2026** - -~~3. **UserOrder/Checkout APIs** - نیاز به بررسی~~ - **✅ تکمیل شد - Feb 6, 2026:** - - ✅ SubmitShopBuyOrder - تبدیل سبد خرید به سفارش - - ✅ GetUserOrder - نمایش جزئیات سفارش - - ✅ GetAllUserOrderByFilter - لیست سفارشات - - ✅ GetVATRate - دریافت نرخ مالیات 9% - - ✅ OrderDetail.razor - رفع باگ‌های NullReference - -4. **UpdateCustomerProfile, ChangeCustomerPassword, UpdateCustomerSettings** - Proto موجود اما Query/Handler نیاز است - ---- - -## اقدامات لازم - -~~### Priority 1: UserCarts Customer Endpoints~~ -~~این APIs برای سبد خرید ضروری هستند.~~ -**✅ تکمیل شد - Feb 5, 2026:** -- ✅ GetCustomerCartQuery و Handler -- ✅ AddToCustomerCartCommand و Handler -- ✅ UpdateCustomerCartItemCommand و Handler -- ✅ RemoveFromCustomerCartCommand و Handler -- ✅ UserCartsService با ISender - -**✅ اصلاحات Security - Feb 6, 2026:** -- ✅ CartService.cs: تمام عملیات به Customer APIs تغییر یافت -- ✅ رفع باگ Field Naming: UserCartId → CartItemId -- ✅ رفع منطق حذف: از Update به RemoveUserCartForCustomer - -~~### Priority 2: UserAddress Customer Endpoints~~ -~~این APIs برای Checkout و مدیریت آدرس‌ها ضروری هستند.~~ -**✅ تکمیل شد - Feb 5, 2026:** -- ✅ GetCustomerAddressesQuery و Handler -- ✅ CreateCustomerAddressCommand و Handler -- ✅ UpdateCustomerAddressCommand و Handler -- ✅ DeleteCustomerAddressCommand و Handler -- ✅ SetCustomerDefaultAddressCommand و Handler -- ✅ UserAddressService با ISender -- ⚠️ **یادداشت:** CityName/ProvinceName در response خالی است - FrontOffice باید از City API استفاده کند - -**✅ اصلاحات Security - Feb 6, 2026:** -- ✅ Addresses.razor: GetCustomerAddresses (قبلاً تمام آدرس‌ها نمایش می‌یافت) -- ✅ Index.razor (Profile): GetCustomerAddresses -- ✅ CheckoutSummary.razor: GetCustomerAddresses -- ✅ Checkout.razor: GetCustomerAddresses -- ✅ AddAddressDialog.razor: CreateCustomerAddress -- ✅ EditAddressDialog.razor: UpdateCustomerAddress - -~~### Priority 3: Checkout/Order Creation~~ -باید workflow ثبت سفارش بررسی شود. - -### Priority 4: Customer Profile Updates -پیاده‌سازی Handler های Update برای Customer. - ---- - -## وضعیت پروژه - -**تکمیل شده:** ~97% -**آخرین به‌روزرسانی:** 6 فوریه 2026 - -**تغییرات Feb 6, 2026:** - -**Phase 1: رفع باگ‌های Critical Security در FrontOffice** -- ✅ **UserAddress Security Bug Fix**: تغییر از Admin APIs به Customer APIs در تمام صفحات - - Addresses.razor, Index.razor (Profile), CheckoutSummary.razor, Checkout.razor - - AddAddressDialog, EditAddressDialog - - قبلاً همه آدرس‌های تمام کاربران نمایش داده می‌شد ⚠️ - - حالا فقط آدرس‌های کاربر لاگین شده (با ICurrentUserService) - -- ✅ **UserCart Security Bug Fix**: تغییر از Admin APIs به Customer APIs در CartService - - تمام عملیات: Add, Update, Remove, Clear - - رفع باگ Field Naming: UserCartId → CartItemId (Proto: cart_item_id) - - رفع منطق حذف: از UpdateUserCart با Count=0 به RemoveUserCartForCustomer - - قبلاً تمام سبدهای خرید تمام کاربران قابل دسترسی بود ⚠️ - -**Phase 2: پیاده‌سازی APIs گم‌شده** -- ✅ **GetVATRate**: پیاده‌سازی در UserOrderService - - نرخ مالیات بر ارزش افزوده ایران: 9% - - استفاده در VATService و Products page - -- ✅ **GetAllProductsByFilter**: پیاده‌سازی در ProductsService - - استفاده از GetCustomerProductsByFilterQuery - - پشتیبانی کامل از filtering, sorting, pagination - - CategoryIds mapping به درستی - -- ✅ **GetAllCategoriesForCustomer**: پیاده‌سازی در CategoryService - - استفاده از GetAllCategoryByFilterQuery - - فقط دسته‌بندی‌های فعال (IsActive = true) - - ISender به CategoryService اضافه شد - - مرتب‌سازی بر اساس SortOrder - -**Phase 3: تکمیل Checkout Workflow** -- ✅ **SubmitShopBuyOrder**: تبدیل سبد خرید به سفارش نهایی با **پرداخت از کیف پول** (Updated Feb 6) - - احراز هویت با ICurrentUserService (UserId از JWT) - - اعتبارسنجی سبد خرید (خالی نباشد) و آدرس پیش‌فرض - - محاسبات مالی: مبلغ پایه + مالیات 9% = مبلغ کل - - **اعتبارسنجی موجودی کیف پول**: Balance >= TotalAmount 🆕 - - **ایجاد تراکنش**: Type=Buy, PaymentStatus=Success, RefId=SHOP_{timestamp} 🆕 - - **کسر از کیف پول**: Balance -= TotalAmount 🆕 - - **ثبت لاگ تغییرات**: UserWalletChangeLog با تمام جزئیات (audit trail) 🆕 - - ایجاد سفارش (UserOrder): **PaymentStatus=Success, PaymentMethod=Wallet, TransactionId** (Updated from Pending) - - ثبت مالیات (OrderVAT): VATRate, BaseAmount, VATAmount, TotalAmount - - ایجاد جزئیات فاکتور (FactorDetails) برای هر محصول - - پاکسازی سبد خرید (soft delete) - - بازگشت OrderId برای redirect - - **خطاها**: "کیف پول یافت نشد", "موجودی کیف پول کافی نیست" - -- ✅ **GetUserOrder**: نمایش جزئیات کامل سفارش - - استفاده از GetCustomerOrderQuery - - اطلاعات سفارش + کاربر + آدرس + مالیات + محصولات + ردیابی - - پشتیبانی از nullable fields (PaymentDate, PaymentMethod) - -- ✅ **GetAllUserOrderByFilter**: لیست سفارشات با فیلتر - - Admin API - می‌تواند همه سفارشات را ببیند - - فیلترها: UserId, PaymentStatus, DeliveryStatus, PaymentDate - - Pagination + Sorting کامل - -- ✅ **OrderDetail.razor - رفع باگ‌های UI**: - - رفع NullReferenceException برای PaymentDate (null برای سفارشات Pending) - - نمایش "تاریخ ثبت" به جای "تاریخ پرداخت" برای سفارشات بدون پرداخت - - رفع نمایش ProductThumbnailPath به جای ProductTitle - - رفع خطاهای nullable value access: .Value → ?? 0 - - محاسبه صحیح subtotal با null coalescing - -**خلاصه تغییرات:** -- 🔒 **Security**: رفع باگ‌های critical در UserAddress و UserCart (همه کاربران قابل مشاهده بودند) -- 📦 **Products**: GetAllProductsByFilter + GetAllCategoriesForCustomer پیاده شد -- 💰 **VAT**: GetVATRate با نرخ 9% ایران -- 🛒 **Checkout**: workflow کامل - سبد خرید → سفارش → نمایش جزئیات -- 🐛 **Bug Fixes**: OrderDetail null handling + Field naming (UserCartId → CartItemId) - -**تغییرات قبلی (Feb 5, 2026):** - -**Phase 1: UserCart & UserAddress Customer Endpoints** -- ✅ پیاده‌سازی کامل UserCart Customer endpoints (4 Handler + Service) -- ✅ پیاده‌سازی کامل UserAddress Customer endpoints (5 Handler + Service) -- ✅ اضافه کردن Proto definitions برای Customer Address - -**Phase 2: NetworkMembership Bug Fixes** -- ✅ GetMyNetworkTree Customer Query (رفع خطای Validation) -- ✅ GetNetworkStatistics Recursive Counting (رفع باگ شمارش نادرست) - -**Phase 3: UserWallet UI Enhancement** -- ✅ رفع باگ نمایش 0 در مبالغ تراکنش‌ها -- ✅ اضافه کردن CurrentDiscountBalance و ChangeDiscountValue به Proto (v0.0.177) -- ✅ جداسازی تراکنش‌ها به 3 نوع کیف پول (عادی، شبکه، تخفیفی) -- ✅ اصلاح نام‌گذاری: "اعتباری" → "عادی" -- ✅ رفع باگ تاریخ: اضافه کردن ToLocalTime() برای تبدیل UTC -- ✅ UI Desktop: جدول با ستون‌های جداگانه برای هر 3 کیف پول -- ✅ UI Mobile: کارت‌ها با 3 باکس افقی (عادی آبی، شبکه سبز، تخفیفی زرد) -- ✅ نمایش همزمان تغییرات و موجودی مانده برای هر کیف پول -- ✅ تغییر FrontOffice.Main.csproj: PackageReference → ProjectReference - -**باقی مانده:** -- ⚠️ Checkout workflow و Order creation (نیاز به بررسی) -- ⚠️ Profile update handlers (UpdateCustomerProfile, ChangePassword, UpdateSettings) -- 📝 CityName/ProvinceName در GetCustomerAddresses خالی است (نیاز به City API lookup در FrontOffice) - -**Build Status:** -- ✅ CMS: 0 Errors, ~60 Warnings (unused proto imports) -- ✅ FrontOffice: 0 Errors, ~120 Warnings (nullable references) - -**صفحات تست شده (Feb 6):** -- ✅ /profile/addresses - کار می‌کند (فقط آدرس‌های خود کاربر) -- ✅ /products - کار می‌کند (لیست محصولات با filtering و sorting) -- ✅ /categories - کار می‌کند (لیست دسته‌بندی‌های فعال) -- ✅ /profile/wallet - کار می‌کند (3 کیف پول با تراکنش‌های کامل) - diff --git a/ICURRENTUSERSERVICE-IMPLEMENTATION.md b/ICURRENTUSERSERVICE-IMPLEMENTATION.md deleted file mode 100644 index efc6df8..0000000 --- a/ICURRENTUSERSERVICE-IMPLEMENTATION.md +++ /dev/null @@ -1,591 +0,0 @@ -# پیاده‌سازی ICurrentUserService در سرویس‌های Customer - -## خلاصه تغییرات -این سند تمام تغییرات انجام شده برای پیاده‌سازی احراز هویت مبتنی بر JWT در endpoint‌های Customer را مستند می‌کند. هدف اصلی حذف نیاز به ارسال صریح UserId از سمت کلاینت و استخراج خودکار آن از JWT Claims است. - -## الگوی پیاده‌سازی - -### الگوی Query Handler (با ICurrentUserService) -```csharp -public class SomeQueryHandler : IRequestHandler -{ - private readonly IApplicationDbContext _context; - private readonly ICurrentUserService _currentUser; - - public SomeQueryHandler(IApplicationDbContext context, ICurrentUserService currentUser) - { - _context = context; - _currentUser = currentUser; - } - - public async Task Handle(SomeQuery request, CancellationToken cancellationToken) - { - // رزولو کردن UserId از JWT اگر در request مشخص نشده باشد - var userId = request.UserId == 0 - ? (long.TryParse(_currentUser.UserId, out var currentUserId) ? currentUserId : 0) - : request.UserId; - - if (userId == 0) - throw new UnauthorizedAccessException("User ID not found"); - - var query = _context.SomeEntity - .Where(x => x.UserId == userId) - .AsNoTracking(); - - // ... ادامه پیاده‌سازی - } -} -``` - -### الگوی Service (استفاده از ISender) -```csharp -public class SomeService : SomeContract.SomeContractBase -{ - private readonly ISender _sender; - - public SomeService(ISender sender) - { - _sender = sender; - } - - public override async Task CustomerEndpoint(Request request, ServerCallContext context) - { - var query = new SomeQuery { UserId = 0 }; // 0 = استفاده از ICurrentUserService - var result = await _sender.Send(query, context.CancellationToken); - return MapToProtoResponse(result); - } -} -``` - -## تصمیمات معماری - -### 1. ISender vs IDispatchRequestToCQRS -- **IDispatchRequestToCQRS**: برای endpoint‌های Admin که ساختار Proto به‌طور مستقیم به CQRS نگاشت می‌شود -- **ISender**: برای endpoint‌های Customer که نیاز به ساخت دستی Query و ساختار متفاوت دارند - -### 2. قرارداد UserId = 0 -- `0` یا مقدار مشخص نشده = استفاده از ICurrentUserService برای دریافت کاربر فعلی از JWT -- مقدار غیر صفر = کاربر صریح (برای عملیات admin/support) - -### 3. مسئولیت Query Handler -- Query Handler باید پس از رزولو کردن userId، وجود آن را validate کند -- در صورت عدم موفقیت در تعیین userId، UnauthorizedAccessException پرتاب شود - -## سرویس‌های پیاده‌سازی شده - -### ✅ 1. UserWallet Service (5 endpoints) - -#### 1.1 GetUserWalletQueryHandler -**فایل**: `CMSMicroservice.Application/UserWalletCQ/Queries/GetUserWallet/GetUserWalletQueryHandler.cs` - -**تغییرات**: -- افزودن `ICurrentUserService` به constructor -- اضافه شدن فیلد `DiscountBalance` به DTO -- پشتیبانی از `Id = 0` برای استفاده از کاربر فعلی - -```csharp -var userId = request.Id == 0 - ? (long.TryParse(_currentUser.UserId, out var currentUserId) ? currentUserId : 0) - : request.Id; -``` - -#### 1.2 GetCustomerWalletChangeLogQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserWalletCQ/Queries/GetCustomerWalletChangeLog/` - -**پیاده‌سازی**: -- Query/Handler جدید برای دریافت تاریخچه تغییرات کیف پول -- استفاده از entity `UserWalletChangeLog` -- پشتیبانی از Pagination -- فیلتر بر اساس userId از ICurrentUserService - -#### 1.3 GetCustomerWithdrawalsQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserWalletCQ/Queries/GetCustomerWithdrawals/` - -**پیاده‌سازی**: -- Query/Handler جدید برای دریافت درخواست‌های برداشت -- استفاده از entity `UserCommissionPayout` -- فیلتر بر اساس `WithdrawalRequestDate` و `status = PayoutRequested` -- پشتیبانی از Pagination - -#### 1.4 GetCustomerWithdrawalSettingsQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserWalletCQ/Queries/GetCustomerWithdrawalSettings/` - -**پیاده‌سازی**: -- Query/Handler جدید برای دریافت تنظیمات برداشت -- مقدار ثابت `MIN_WITHDRAWAL_AMOUNT = 50000` -- برگرداندن موجودی کیف پول کاربر فعلی - -#### 1.5 UserWalletService -**فایل**: `CMSMicroservice.WebApi/Services/UserWalletService.cs` - -**تغییرات**: -- افزودن `ISender` به constructor -- پیاده‌سازی 4 متد Customer با استفاده از Query Handler‌های واقعی: - - `GetCustomerWallet` - - `GetCustomerWalletChangeLog` - - `GetCustomerWithdrawals` - - `GetCustomerWithdrawalSettings` - ---- - -### ✅ 2. Commission Service (2 endpoints) - -#### 2.1 GetUserCommissionPayoutsQueryHandler -**فایل**: `CMSMicroservice.Application/CommissionCQ/Queries/GetUserCommissionPayouts/GetUserCommissionPayoutsQueryHandler.cs` - -**تغییرات**: -- افزودن `ICurrentUserService` به constructor -- پشتیبانی از `UserId = null` یا `0` برای استفاده از کاربر فعلی -- کوئری از `UserCommissionPayouts` با Include کردن `WeekDefinition` - -#### 2.2 GetUserWeeklyBalancesQueryHandler -**فایل**: `CMSMicroservice.Application/CommissionCQ/Queries/GetUserWeeklyBalances/GetUserWeeklyBalancesQueryHandler.cs` - -**تغییرات**: -- افزودن `ICurrentUserService` به constructor -- همان الگوی رزولو UserId -- کوئری از `UserWeeklyBalances` با Include کردن `WeekDefinition` - ---- - -### ✅ 3. NetworkMembership Service (3 endpoints) - -#### 3.1 GetNetworkTreeQueryHandler -**فایل**: `CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkTree/GetNetworkTreeQueryHandler.cs` - -**تغییرات**: -- افزودن `ICurrentUserService` به constructor -- پشتیبانی از `UserId = 0` برای استفاده از کاربر فعلی -- اجرای Stored Procedure `[CMS].[GetNetworkTree]` -- تبدیل نتایج flat SP به ساختار درختی hierarchical - -#### 3.2 GetNetworkStatisticsQueryHandler -**فایل**: `CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkStatistics/GetNetworkStatisticsQueryHandler.cs` - -**تغییرات**: -- افزودن پارامتر `UserId` به Query -- افزودن `ICurrentUserService` به constructor -- تغییر منطق از آمار کل سیستم به آمار شبکه زیرمجموعه کاربر -- فیلتر: `x.NetworkParentId == userId` (نه `x.NetworkParentId != null`) - -#### 3.3 NetworkMembershipService -**فایل**: `CMSMicroservice.WebApi/Services/NetworkMembershipService.cs` - -**تغییرات**: -- افزودن `ISender` به constructor -- پیاده‌سازی 3 متد Customer: - - `GetMyNetworkTree`: درخت شبکه کاربر فعلی با UserId=0 - - `GetSubordinateTree`: درخت زیرمجموعه خاص (برای admin) - - `GetMyNetworkStatistics`: آمار شبکه کاربر فعلی -- متدهای helper: - - `ConvertToNodeModel()`: تبدیل بازگشتی DTO به Proto Model - - `CountNodes()`: شمارش بازگشتی node‌های درخت - -**رفع باگ**: -- حذف فیلدهای `IsClubActive` و `ActivationWeekDefinitionId` که در Proto request وجود نداشتند - ---- - -### ✅ 4. Package Service (3 query endpoints) - -#### 4.1 GetCustomerPackagesQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/PackageCQ/Queries/GetCustomerPackages/` - -**پیاده‌سازی**: -- Query/Handler جدید برای دریافت لیست پکیج‌ها -- کوئری از entity `Package` -- نگاشت فیلدهای اضافی: - - `Name = Title` - - `ImageUrl = ImagePath` - - `Currency = "IRR"` - - `ValidityDays = 365` -- پشتیبانی از فیلتر `PackageType` (در صورت وجود در entity) - -#### 4.2 GetCustomerPackageDetailsQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/PackageCQ/Queries/GetCustomerPackageDetails/` - -**پیاده‌سازی**: -- Query/Handler جدید برای دریافت جزئیات یک پکیج -- کوئری بر اساس `PackageId` -- افزودن Features (کمیسیون، پشتیبانی، آموزش) -- افزودن Requirements (عضویت، موجودی کیف پول، محدودیت‌ها) - -#### 4.3 GetCustomerPurchaseHistoryQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/PackageCQ/Queries/GetCustomerPurchaseHistory/` - -**پیاده‌سازی**: -- Query/Handler جدید با ICurrentUserService -- کوئری از `UserOrders` با فیلتر `PackageId != null` -- Include کردن navigation property `Package` -- پشتیبانی از: - - Pagination - - فیلتر تاریخ (FromDate, ToDate) - - فیلتر نوع پکیج -- نگاشت `PaymentStatus` صحیح (Success/Reject/Pending) -- دریافت `RefId` از Transaction (نه `ReferenceId`) - -#### 4.4 PackageService -**فایل**: `CMSMicroservice.WebApi/Services/PackageService.cs` - -**تغییرات**: -- افزودن `ISender` به constructor -- افزودن namespace alias: `using AppModels = CMSMicroservice.Application.Common.Models;` -- جایگزینی 3 متد MOCK با Query Handler واقعی: - - `GetCustomerPackages` - - `GetCustomerPackageDetails` - - `GetCustomerPurchaseHistory` -- رفع ابهام در type‌های `PaginationState` و `MetaData` با استفاده از alias -- متدهای Command (Purchase, Verify) همچنان MOCK باقی ماندند - ---- - -## مشکلات رفع شده - -### 1. خطای Type Inference با IDispatchRequestToCQRS -**خطا**: `CS1061: 'Empty' does not contain definition for 'Balance'` - -**علت**: استفاده از overload نادرست `Handle` که compiler نوع‌ها را اشتباه استنباط می‌کرد - -**راه حل**: استفاده از `ISender.Send()` به‌جای `IDispatchRequestToCQRS` برای endpoint‌های Customer - -### 2. عدم تطابق فیلدهای Proto -**خطا**: `CS1061: GetSubordinateTreeRequest doesn't have ActivationWeekDefinitionId` - -**علت**: کد سرویس فیلدهایی را فرض می‌کرد که در Proto تعریف نشده بودند - -**راه حل**: حذف فیلدهای غیرموجود از نگاشت request - -### 3. خطای Nullable Protobuf Wrapper -**خطا**: `CS1061: 'long' doesn't contain 'Value' property` - -**علت**: تلاش برای فراخوانی `.Value` روی type‌های non-nullable - -**راه حل**: حذف فراخوانی `.Value` و انتساب مستقیم - -### 4. خطای Transaction.ReferenceId -**خطا**: `CS1061: 'Transaction' does not contain a definition for 'ReferenceId'` - -**علت**: نام صحیح فیلد `RefId` است نه `ReferenceId` - -**راه حل**: تغییر به `Transaction.RefId` - -### 5. خطای PaymentStatus Enum Values -**خطا**: `CS0117: 'PaymentStatus' does not contain a definition for 'Failed'/'Refunded'` - -**علت**: enum فقط دارای مقادیر `Success`, `Reject`, `Pending` است - -**راه حل**: تصحیح switch statement به مقادیر صحیح - -### 6. خطای Ambiguous Reference -**خطا**: `CS0104: 'PaginationState'/'MetaData' is ambiguous` - -**علت**: type‌ها هم در `CMSMicroservice.Application.Common.Models` و هم در `CMSMicroservice.Protobuf.Protos` وجود دارند - -**راه حل**: افزودن namespace alias: `using AppModels = CMSMicroservice.Application.Common.Models;` - -### 7. خطای MetaData Constructor -**خطا**: `CS1729: 'MetaData' does not contain a constructor that takes 3 arguments` - -**علت**: MetaData class در Application layer بدون constructor است - -**راه حل**: استفاده از object initializer به‌جای constructor: -```csharp -var metaData = new MetaData -{ - TotalCount = totalCount, - CurrentPage = pageNumber, - PageSize = pageSize, - TotalPage = (int)Math.Ceiling((double)totalCount / pageSize), - HasPrevious = pageNumber > 1, - HasNext = pageNumber < totalPages -}; -``` - -### 8. خطای CategoryIds در Proto -**خطا**: `CS1061: 'GetAllProductsByFilterFilter' does not contain 'CategoryIds'` - -**علت**: Proto فقط `category_id` (singular) دارد نه `category_ids` - -**راه حل**: تبدیل single value به List: -```csharp -CategoryIds = request.Filter?.CategoryId != null - ? new List { request.Filter.CategoryId.Value } - : null -``` - -### 9. خطای OrderVAT و DeliveryStatus -**خطا**: `CS1061: 'OrderVAT' does not contain 'VATPercentage'` - -**علت**: -- فیلد صحیح `VATRate` است (decimal) -- enum‌های `Processing` و `Shipped` وجود ندارند - -**راه حل**: -- استفاده از `VATRate * 100` برای درصد -- تصحیح enum values: `Pending`, `InTransit`, `Delivered`, `Cancelled`, `Returned` - -### 10. خطای Transaction/UserWalletChangeLog بدون UserId -**خطا**: `CS1061: 'Transaction/UserWalletChangeLog' does not contain 'UserId'` - -**علت**: این entity‌ها direct UserId ندارند - -**راه حل**: query از طریق navigation properties: -```csharp -// Transaction -.Include(x => x.UserOrders) -.Where(x => x.UserOrders.Any(o => o.UserId == userId)) - -// UserWalletChangeLog -.Include(x => x.Wallet) -.Where(x => x.Wallet.UserId == userId) -``` - ---- - -### ✅ 5. UserOrder Service (3 endpoints) - -#### 5.1 GetCustomerOrdersQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserOrderCQ/Queries/GetCustomerOrders/` - -**پیاده‌سازی**: -- Query/Handler جدید با ICurrentUserService -- کوئری از `UserOrders` با Include: - - Package, Transaction, UserAddress, User, FactorDetails, OrderVAT -- پشتیبانی از Pagination -- محاسبه `TotalAmount` با احتساب مالیات (`VATRate * 100`) - -**رفع باگ**: -- `OrderVAT.VATPercentage` وجود ندارد → استفاده از `VATRate * 100` -- `DeliveryStatus.Processing/Shipped` وجود ندارد → `Pending/InTransit` - -#### 5.2 GetCustomerOrderQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserOrderCQ/Queries/GetCustomerOrder/` - -**پیاده‌سازی**: -- Query/Handler برای دریافت یک سفارش با OrderId -- Validation: بررسی تعلق Order به UserId فعلی -- Include همان navigation properties - -#### 5.3 GetCustomerOrderHistoryQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserOrderCQ/Queries/GetCustomerOrderHistory/` - -**پیاده‌سازی**: -- Query/Handler با Pagination و فیلترها -- فیلترهای پشتیبانی شده: - - FromDate, ToDate - - PaymentStatus, DeliveryStatus -- محاسبه `CanCancelOrder` بر اساس شرایط: - - PaymentStatus = Pending - - DeliveryStatus = None یا Pending - -#### 5.4 UserOrderService -**فایل**: `CMSMicroservice.WebApi/Services/UserOrderService.cs` - -**تغییرات**: -- افزودن ISender به constructor -- پیاده‌سازی 3 متد Customer با Query Handler واقعی -- استفاده از namespace alias برای حل ambiguity - ---- - -### ✅ 6. Transaction Service (2 endpoints) - -#### 6.1 GetCustomerTransactionQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/TransactionsCQ/Queries/GetCustomerTransaction/` - -**پیاده‌سازی**: -- Query/Handler با ICurrentUserService -- **چالش**: Transaction entity بدون UserId -- **راه حل**: query از طریق `UserOrders` navigation: - ```csharp - .Include(x => x.UserOrders) - .Where(x => x.UserOrders.Any(o => o.UserId == userId)) - ``` -- فیلتر بر اساس Id یا Authority - -#### 6.2 GetCustomerTransactionsByFilterQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/TransactionsCQ/Queries/GetCustomerTransactionsByFilter/` - -**پیاده‌سازی**: -- Query/Handler با Pagination -- فیلترهای پشتیبانی شده: - - Id, Amount, Description - - PaymentStatus (bool), RefId, Type -- همان الگوی query از طریق UserOrders - -#### 6.3 TransactionsService -**فایل**: `CMSMicroservice.WebApi/Services/TransactionsService.cs` - -**تغییرات**: -- افزودن ISender و Query imports -- جایگزینی MOCK با Query Handler واقعی -- mapping صحیح Proto enums - ---- - -### ✅ 7. Products Service (2 endpoints) - -#### 7.1 GetCustomerProductsQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/ProductsCQ/Queries/GetCustomerProducts/` - -**پیاده‌سازی**: -- Query/Handler بدون ICurrentUserService (محصولات عمومی) -- کوئری از `Products` با Include: - - ProductGalleries.ProductImage - - ProductCategories.Category -- ساخت درختی Category Path با متد `BuildCategoryPath()` -- بازگشت بازگشتی به parent categories - -#### 7.2 GetCustomerProductsByFilterQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/ProductsCQ/Queries/GetCustomerProductsByFilter/` - -**پیاده‌سازی**: -- Query/Handler با Pagination -- فیلترهای کامل: - - Id, Title, Description, ShortInfomation, FullInformation - - Price, Discount, Rate - - SaleCount, ViewCount, RemainingCount - - CategoryIds (لیست شناسه دسته‌بندی‌ها) -- Sorting پویا با `ApplyOrder()` - -#### 7.3 ProductsService -**فایل**: `CMSMicroservice.WebApi/Services/ProductsService.cs` - -**تغییرات**: -- افزودن ISender به constructor -- پیاده‌سازی 2 متد Customer -- mapping دستی Gallery و Categories به Proto structures -- **رفع باگ**: Proto فقط `category_id` دارد نه `category_ids` - - تبدیل single value به List - ---- - -### ✅ 8. User Service (3 endpoints) - -#### 8.1 GetCustomerProfileQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserCQ/Queries/GetCustomerProfile/` - -**پیاده‌سازی**: -- Query/Handler با ICurrentUserService -- دریافت پروفایل کامل کاربر فعلی -- محاسبه `ProfileCompletionPercentage` بر اساس 10 فیلد: - - FirstName, LastName, Mobile, Email, NationalCode - - AvatarPath, BirthDate, IsMobileVerified - - NetworkParentId, ReferralCode -- محاسبه `FullName` از FirstName + LastName - -#### 8.2 GetCustomerReferralsQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserCQ/Queries/GetCustomerReferrals/` - -**پیاده‌سازی**: -- Query/Handler با ICurrentUserService و Pagination -- کوئری کاربران با `NetworkParentId == userId` -- فیلتر بر اساس StatusFilter (ACTIVE/INACTIVE/ALL) -- محاسبه آمار: - - TotalReferrals, ActiveReferrals - - TotalCommissionEarned از `UserWallet.NetworkBalance` - - ThisMonthCommission از `UserWalletChangeLog` -- **رفع باگ**: UserWalletChangeLog بدون UserId - - راه حل: `.Include(x => x.Wallet).Where(x => x.Wallet.UserId == userId)` - -#### 8.3 GetCustomerSettingsQueryHandler (جدید) -**فایل**: `CMSMicroservice.Application/UserCQ/Queries/GetCustomerSettings/` - -**پیاده‌سازی**: -- Query/Handler ساده برای دریافت تنظیمات کاربر -- فیلدهای موجود در User entity: - - EmailNotifications, SmsNotifications, PushNotifications -- مقادیر پیش‌فرض برای فیلدهای ناموجود: - - MarketingNotifications = false - - PreferredLanguage = "fa" - - TimeZone = "Asia/Tehran" - - TwoFactorAuthEnabled = false - -#### 8.4 UserService -**فایل**: `CMSMicroservice.WebApi/Services/UserService.cs` - -**تغییرات**: -- افزودن ISender و Query imports -- پیاده‌سازی 3 متد Customer با Query Handler واقعی -- تبدیل DateTime به Timestamp با `SpecifyKind(DateTimeKind.Utc)` -- **رفع ambiguity**: fully qualified names برای CustomerReferralStats و CustomerReferralModel - ---- - -## آمار پیشرفت - -### سرویس‌های تکمیل شده (8/8): ✅ 100% -✅ **UserWallet** (5 endpoints) -✅ **Commission** (2 endpoints) -✅ **NetworkMembership** (3 endpoints) -✅ **Package** (3 endpoints) -✅ **UserOrder** (3 endpoints) -✅ **Transaction** (2 endpoints) -✅ **Products** (2 endpoints) -✅ **User** (3 endpoints) - -**جمع کل**: **25 endpoint** با الگوی ICurrentUserService پیاده‌سازی شد - ---- - -## نکات فنی - -### Entity Navigation Properties -همیشه از `.Include()` برای load کردن navigation property‌های مورد نیاز استفاده شود: -```csharp -query = query.Include(x => x.Package) - .Include(x => x.Transaction); -``` - -### Pagination -از extension method‌های `GetMetaData` و `PaginatedListAsync` استفاده شود: -```csharp -var metaData = await query.GetMetaData(request.PaginationState, cancellationToken); -var items = await query.PaginatedListAsync(request.PaginationState).ToListAsync(cancellationToken); -``` - -### DateTime Mapping -برای تبدیل به Protobuf Timestamp، DateTime باید UTC باشد: -```csharp -Timestamp.FromDateTime(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc)) -``` - -### Enum Casting -برای نگاشت enum‌ها بین Application و Proto: -```csharp -Status = (PaymentStatusEnum)order.PaymentStatus -``` - ---- - -## Build Status -✅ **آخرین Build موفق**: 0 Error(s), 66 Warning(s) - Time Elapsed 00:00:03.55 - ---- - -## تاریخ آخرین به‌روزرسانی -5 فوریه 2026 - ---- - -## نتیجه‌گیری -پیاده‌سازی ICurrentUserService در **25 endpoint** مربوط به **8 سرویس** با موفقیت کامل شد. - -### دستاوردها: -- ✅ **100% Coverage**: تمام endpoint‌های Customer پیاده‌سازی شدند -- ✅ **الگوی Consistent**: pattern مشخص برای تمام سرویس‌ها -- ✅ **امنیت بالا**: استخراج خودکار UserId از JWT -- ✅ **قابلیت نگهداری**: کد تمیز و قابل فهم -- ✅ **Build موفق**: بدون هیچ خطا - -### چالش‌های حل شده: -- Entity‌های بدون UserId (Transaction, UserWalletChangeLog) -- Proto/Application type ambiguity -- MetaData بدون constructor -- Category path building -- Proto enum mapping -- DateTime UTC conversion - -تمام تغییرات compile می‌شوند و آماده تست و deployment هستند. - - diff --git a/MIGRATION-PROGRESS.md b/MIGRATION-PROGRESS.md deleted file mode 100644 index 164239d..0000000 --- a/MIGRATION-PROGRESS.md +++ /dev/null @@ -1,179 +0,0 @@ -# FrontOffice.BFF to CMS Migration Progress - -## Migration Overview -مهاجرت سرویس‌های FrontOffice.BFF به CMS Microservice با معماری Clean Architecture و gRPC. - -## ✅ Completed Services - -### 1. Categories Service -- **Status**: ✅ Complete -- **Proto Definition**: `categories.proto` -- **Service Implementation**: `CategoryService.cs` -- **Methods Migrated**: - - Admin Methods: - - `AddNewCategory` - افزودن دسته‌بندی جدید - - `UpdateCategory` - بروزرسانی دسته‌بندی - - `DeleteCategory` - حذف دسته‌بندی - - `GetCategory` - دریافت یک دسته‌بندی - - `GetAllCategoriesByFilter` - دریافت لیست دسته‌بندی‌ها - - Customer Methods: - - `GetActiveCategoriesForCustomer` - دریافت دسته‌بندی‌های فعال برای مشتری - -### 2. City Service -- **Status**: ✅ Complete -- **Proto Definition**: `city.proto` -- **Service Implementation**: `CityService.cs` -- **Methods Migrated**: - - Admin Methods: - - `AddNewCity` - افزودن شهر جدید - - `UpdateCity` - بروزرسانی شهر - - `DeleteCity` - حذف شهر - - `GetCity` - دریافت یک شهر - - `GetAllCitiesByFilter` - دریافت لیست شهرها - - Customer Methods: - - `GetActiveCitiesForCustomer` - دریافت شهرهای فعال برای مشتری - -### 3. UserCarts Service -- **Status**: ✅ Complete -- **Proto Definition**: `usercarts.proto` -- **Service Implementation**: `UserCartsService.cs` -- **Methods Migrated**: - - Admin Methods: - - `AddNewUserCart` - افزودن سبد خرید جدید - - `UpdateUserCart` - بروزرسانی سبد خرید - - `DeleteUserCart` - حذف سبد خرید - - `GetUserCart` - دریافت سبد خرید (Admin) - - `GetAllUserCartsByFilter` - دریافت لیست سبدهای خرید - - Customer Methods: - - `AddNewUserCartForCustomer` - افزودن محصول به سبد (Customer) - - `UpdateUserCartForCustomer` - بروزرسانی تعداد محصول در سبد - - `RemoveUserCartForCustomer` - حذف محصول از سبد - - `GetCustomerCart` - دریافت سبد خرید مشتری - -## 🛠️ Technical Implementation Details - -### gRPC HTTP Annotations -تمام سرویس‌ها با HTTP annotations تعریف شده‌اند: -- Admin endpoints: `/ServiceName` pattern -- Customer endpoints: `/Customer/Action` pattern - -### Clean Architecture Structure -``` -CMSMicroservice.Domain/ # Core business entities -CMSMicroservice.Application/ # Business logic & CQRS -CMSMicroservice.Infrastructure/ # Data access & external services -CMSMicroservice.WebApi/ # gRPC services & controllers -CMSMicroservice.Protobuf/ # Protocol buffer definitions -``` - -### Swagger Integration -- Multiple Swagger documents: cms, admin, customer, unified -- gRPC HTTP transcoding enabled -- Custom CSS styling applied -- Conflict resolution implemented - -## 🔧 Issues Resolved - -### 1. Swagger Conflict Resolution -**Problem**: -``` -Swashbuckle.AspNetCore.SwaggerGen.SwaggerGeneratorException: -Conflicting method/path combination "GET GetUserCart" -``` - -**Root Cause**: -- دو method با operation ID یکسان: `GetUserCart` و `GetUserCartForCustomer` -- Swagger از method name برای operation ID استفاده می‌کند - -**Solutions Attempted**: -1. ❌ `CustomOperationIds` - ineffective -2. ❌ `ResolveConflictingActions` - incomplete resolution -3. ✅ **Method Renaming** - successful - -**Final Solution**: -```protobuf -// Before (conflicting): -rpc GetUserCartForCustomer(GetUserCartForCustomerRequest) returns (GetUserCartForCustomerResponse) - -// After (resolved): -rpc GetCustomerCart(GetUserCartForCustomerRequest) returns (GetUserCartForCustomerResponse) -``` - -### 2. Application Layer Dependencies -**Problem**: Build errors در Application layer -**Solution**: پاکسازی dependencies و rebuild پروژه - -## 📊 Migration Status Summary - -| Service | Proto ✅ | Implementation ✅ | Build ✅ | Swagger ✅ | -|---------|----------|-------------------|----------|------------| -| Categories | ✅ | ✅ | ✅ | ✅ | -| City | ✅ | ✅ | ✅ | ✅ | -| UserCarts | ✅ | ✅ | ✅ | ✅ | - -## 🎯 Next Steps -1. **Service Integration Testing** - تست عملکرد سرویس‌های migrate شده -2. **Business Logic Implementation** - پیاده‌سازی منطق کسب‌وکار واقعی -3. **Database Integration** - اتصال به لایه دیتا -4. **Continue Migration** - ادامه migration سایر سرویس‌ها - -## 🏗️ Technical Architecture - -### gRPC Service Pattern -```csharp -public class ServiceName : ServiceContract.ServiceContractBase -{ - private readonly IDispatchRequestToCQRS _dispatcher; - - // Customer Methods Section - #region Customer Methods - public override async Task CustomerMethod(Request request, ServerCallContext context) - { - // Implementation - } - #endregion - - // Admin Methods Section - #region Admin Methods - public override async Task AdminMethod(Request request, ServerCallContext context) - { - // Implementation - } - #endregion -} -``` - -### Proto File Structure -```protobuf -syntax = "proto3"; -import "google/api/annotations.proto"; - -service ServiceContract { - // ============= Admin Methods ============= - rpc AdminMethod(Request) returns (Response) { - option (google.api.http) = { - post: "/AdminEndpoint" - body: "*" - }; - }; - - // ============= Customer Methods ============= - rpc CustomerMethod(Request) returns (Response) { - option (google.api.http) = { - get: "/Customer/Endpoint" - }; - }; -} -``` - -## 📈 Performance & Quality -- ✅ All services compile successfully -- ✅ Swagger documentation accessible -- ✅ gRPC HTTP transcoding working -- ✅ Clean separation of Admin/Customer concerns -- ✅ Consistent naming conventions applied - ---- -**Last Updated**: January 30, 2026 -**Migration Phase**: Foundation Services Complete -**Next Milestone**: Business Logic Implementation \ No newline at end of file diff --git a/docs/INVENTORY-REFACTORING-STATUS.md b/docs/INVENTORY-REFACTORING-STATUS.md deleted file mode 100644 index 954d676..0000000 --- a/docs/INVENTORY-REFACTORING-STATUS.md +++ /dev/null @@ -1,190 +0,0 @@ -# وضعیت Refactoring سیستم انبارداری (Inventory) - -**تاریخ:** ۳ ژانویه ۲۰۲۶ -**وضعیت:** ✅ تکمیل شده - Build موفق - ---- - -## 📊 وضعیت Build - -| پروژه | وضعیت | -|-------|--------| -| CMSMicroservice.Domain | ✅ OK | -| CMSMicroservice.Application | ✅ OK | -| CMSMicroservice.Infrastructure | ✅ OK | -| CMSMicroservice.WebApi | ✅ OK | - ---- - -## ✅ کارهای انجام شده - -### 1. حذف Repository Pattern -فایل‌های حذف شده: -- `Application/Common/Interfaces/Repositories/IInventoryItemRepository.cs` -- `Application/Common/Interfaces/Repositories/IStockMovementRepository.cs` -- `Application/Common/Interfaces/Repositories/IWarehouseRepository.cs` -- `Infrastructure/Persistence/Repositories/InventoryItemRepository.cs` -- `Infrastructure/Persistence/Repositories/StockMovementRepository.cs` -- `Infrastructure/Persistence/Repositories/WarehouseRepository.cs` - -### 2. حذف Features قدیمی -فولدر حذف شده: -- `Application/Features/` (کل فولدر) - -### 3. ایجاد ساختار CQ جدید - -#### WarehouseCQ/ -``` -WarehouseCQ/ -├── Commands/ -│ ├── CreateWarehouse/ -│ ├── UpdateWarehouse/ -│ ├── DeleteWarehouse/ -│ └── SetDefaultWarehouse/ -└── Queries/ - ├── GetWarehouse/ - ├── GetAllWarehouses/ - └── SearchWarehouses/ -``` - -#### InventoryItemCQ/ -``` -InventoryItemCQ/ -├── Commands/ -│ ├── CreateInventoryItem/ -│ ├── UpdateInventoryItem/ -│ ├── DeleteInventoryItem/ -│ ├── UpdateInventoryQuantity/ -│ ├── ReserveInventory/ -│ ├── ReleaseReservedInventory/ -│ ├── ReduceInventory/ -│ └── IncreaseInventory/ -└── Queries/ - ├── GetInventoryItem/ - ├── GetInventoryByProduct/ - ├── GetAllInventoryItems/ - └── GetLowStockItems/ -``` - -#### StockMovementCQ/ -``` -StockMovementCQ/ -├── Commands/ -│ └── CreateStockMovement/ -└── Queries/ - ├── GetStockMovements/ - └── GetStockMovementsByInventoryItem/ -``` - -### 4. Fix شدن InventoryProfile.cs -- اصلاح enum names: `ProtoProductType.Unspecified` بجای `ProductTypeUnspecified` -- حذف `new Int64Value` - Proto مستقیم `long?` میگیره -- اصلاح expression tree برای `?.` operator - -### 5. ساده‌سازی InventoryService.cs -- متدهای اصلی (Warehouse, Query ها) کامل پیاده‌سازی شدن -- متدهای پیچیده که نیاز به lookup دارن فعلاً TODO هستن - ---- - -## ⚠️ متدهای TODO در InventoryService - -این متدها نیاز به پیاده‌سازی دارن (وقتی لازم شد): - -| متد | دلیل TODO | -|-----|-----------| -| `AddStock` | نیاز به lookup با ProductId/ProductType | -| `AdjustStock` | نیاز به lookup با ProductId/ProductType | -| `ReserveStock` | نیاز به lookup با ProductId/ProductType | -| `ReleaseReservation` | نیاز به lookup با ProductId/ProductType | -| `ConfirmSale` | نیاز به lookup با ProductId/ProductType | -| `ProcessReturn` | نیاز به lookup با ProductId/ProductType | -| `RecordLoss` | نیاز به lookup با ProductId/ProductType | -| `BulkAddStock` | نیاز به loop و lookup | -| `BulkAdjustStock` | نیاز به loop و lookup | -| `GetInventorySummary` | نیاز به Query جدید | -| `GetStockValueReport` | نیاز به Query جدید | - ---- - -## 🎯 درس‌های آموخته شده - -1. **همیشه اول Proto رو بررسی کن** - Proto مرجع اصلی API هست -2. **ساختار موجود رو تحلیل کن** - قبل از ساختن فایل جدید، نمونه‌های موجود رو ببین -3. **Mapping از Proto به Command** - نه برعکس! -4. **IApplicationDbContext** - الگوی استاندارد این پروژه برای دسترسی به DB -5. **بدون Repository** - این پروژه از Repository pattern استفاده نمیکنه -6. **Proto enum names** - نام‌ها در C# متفاوت هستن (مثلاً `Unspecified` بجای `PRODUCT_TYPE_UNSPECIFIED`) -7. **Int64Value در Proto** - در C# به `long?` تبدیل میشه، نیازی به `new Int64Value` نیست - ---- - -## 🔄 همگام‌سازی BFF با CMS (۳ ژانویه ۲۰۲۶) - -### تغییرات Proto -BackOffice.BFF.Inventory.Protobuf با CMS همگام شد: - -| آیتم | قبل | بعد | -|------|-----|-----| -| ProductType enum | `REGULAR`, `DISCOUNT` | `REGULAR_PRODUCT`, `DISCOUNT_PRODUCT` | -| StockMovementType | Sequential (0-9) | Grouped (10, 20, 30, 40, 50) | -| Pagination | `page_index` | `page` | -| Search | `search_term` | `search` | -| Product name | `product_name` | `product_title` | - -### فایل‌های آپدیت شده در BFF - -**Commands:** -- `AddStock` - حذف Success, Message از Response -- `AdjustStock` - Note→Reason, +ReferenceNumber -- `RecordLoss` - Note→Reason, +ReferenceNumber -- `UpdateInventorySettings` - InventoryItemId→Id - -**Queries:** -- `GetAllInventoryItems` - PageIndex→Page, SearchTerm→Search, +ProductPrice -- `GetStockMovements` - PageIndex→Page, +ProductTitle, +Created -- `GetLowStockItems` - حذف Count، استفاده از Page/PageSize -- `GetAllWarehouses` - ActiveOnly→IsActive, +Created, +LastModified - -**Mappings:** -- `InventoryProfile.cs` - بازنویسی کامل برای فیلدهای جدید - -### وضعیت Build BFF -``` -Build succeeded. - 0 Warning(s) - 0 Error(s) -``` - ---- - -## 📊 پوشش API - مقایسه CMS و BFF - -| عملیات | CMS | BFF | یادداشت | -|--------|-----|-----|---------| -| GetAllInventoryItems | ✅ | ✅ | همگام | -| GetInventoryItem | ✅ | ✅ | همگام | -| GetLowStockItems | ✅ | ✅ | همگام | -| GetStockMovements | ✅ | ✅ | همگام | -| GetAllWarehouses | ✅ | ✅ | همگام | -| AddStock | ✅ | ✅ | همگام | -| AdjustStock | ✅ | ✅ | همگام | -| RecordLoss | ✅ | ✅ | همگام | -| CreateWarehouse | ✅ | ✅ | همگام | -| UpdateWarehouse | ✅ | ❌ | نیاز به پیاده‌سازی | -| UpdateInventorySettings | ✅ | ✅ | همگام | -| GetInventorySummary | TODO | ❌ | اولویت بالا | -| GetStockValueReport | TODO | ❌ | اولویت بالا | -| ProcessReturn | TODO | ❌ | اولویت متوسط | - ---- - -## 📝 نتیجه‌گیری - -✅ **Refactoring با موفقیت تکمیل شد!** - -- Application layer با ساختار `*CQ/Commands/[Action]/` سازگار شد -- Repository pattern کاملاً حذف شد -- WebApi layer با Proto سازگار شد -- Build همه پروژه‌ها موفق هست -- **BFF کاملاً با CMS همگام شد (۳ ژانویه ۲۰۲۶)** diff --git a/docs/MOVED-TO-TOTALDOC.md b/docs/MOVED-TO-TOTALDOC.md new file mode 100644 index 0000000..8e394a8 --- /dev/null +++ b/docs/MOVED-TO-TOTALDOC.md @@ -0,0 +1 @@ +Docs moved to /totalDoc — see totalDoc/INDEX.md diff --git a/docs/club-feature-management-services.md b/docs/club-feature-management-services.md deleted file mode 100644 index 851ba2d..0000000 --- a/docs/club-feature-management-services.md +++ /dev/null @@ -1,490 +0,0 @@ -# Club Feature Management Services - Implementation Guide - -## Overview -Admin services for managing user club features (enable/disable features per user). - -## Created Files - -### 1. CQRS Layer (Application) - -#### Query: GetUserClubFeatures -**Location:** `/CMS/src/CMSMicroservice.Application/ClubFeatureCQ/Queries/GetUserClubFeatures/` - -**Files:** -- `GetUserClubFeaturesQuery.cs` - Query definition -- `GetUserClubFeaturesQueryHandler.cs` - Query handler -- `UserClubFeatureDto.cs` - Response DTO - -**Purpose:** Get list of all club features for a specific user with their active status. - -**Input:** -```csharp -public record GetUserClubFeaturesQuery : IRequest> -{ - public long UserId { get; init; } -} -``` - -**Output:** -```csharp -public class UserClubFeatureDto -{ - public long Id { get; set; } - public long UserId { get; set; } - public long ClubMembershipId { get; set; } - public long ClubFeatureId { get; set; } - public string FeatureTitle { get; set; } - public string? FeatureDescription { get; set; } - public bool IsActive { get; set; } - public DateTime GrantedAt { get; set; } - public string? Notes { get; set; } -} -``` - -**Logic:** -- Joins `UserClubFeatures` with `ClubFeature` table -- Filters by `UserId` and `!IsDeleted` -- Returns list of features with their active status - ---- - -#### Command: ToggleUserClubFeature -**Location:** `/CMS/src/CMSMicroservice.Application/ClubFeatureCQ/Commands/ToggleUserClubFeature/` - -**Files:** -- `ToggleUserClubFeatureCommand.cs` - Command definition -- `ToggleUserClubFeatureCommandHandler.cs` - Command handler -- `ToggleUserClubFeatureResponse.cs` - Response DTO - -**Purpose:** Enable or disable a specific club feature for a user. - -**Input:** -```csharp -public record ToggleUserClubFeatureCommand : IRequest -{ - public long UserId { get; init; } - public long ClubFeatureId { get; init; } - public bool IsActive { get; init; } -} -``` - -**Output:** -```csharp -public class ToggleUserClubFeatureResponse -{ - public bool Success { get; set; } - public string Message { get; set; } - public long? UserClubFeatureId { get; set; } - public bool? IsActive { get; set; } -} -``` - -**Validations:** -1. ✅ User exists and not deleted -2. ✅ Club feature exists and not deleted -3. ✅ User has this feature assigned (exists in UserClubFeatures) - -**Logic:** -- Find `UserClubFeature` record by `UserId` + `ClubFeatureId` -- Update `IsActive` field -- Set `LastModified` timestamp -- Save changes - -**Error Messages:** -- "کاربر یافت نشد" - User not found -- "ویژگی باشگاه یافت نشد" - Club feature not found -- "این ویژگی برای کاربر یافت نشد" - User doesn't have this feature - -**Success Messages:** -- "ویژگی با موفقیت فعال شد" - Feature activated successfully -- "ویژگی با موفقیت غیرفعال شد" - Feature deactivated successfully - ---- - -### 2. gRPC Layer (Protobuf + WebApi) - -#### Proto Definition -**File:** `/CMS/src/CMSMicroservice.Protobuf/Protos/clubmembership.proto` - -**Added RPC Methods:** -```protobuf -rpc GetUserClubFeatures(GetUserClubFeaturesRequest) returns (GetUserClubFeaturesResponse){ - option (google.api.http) = { - get: "/ClubFeature/GetUserFeatures" - }; -}; - -rpc ToggleUserClubFeature(ToggleUserClubFeatureRequest) returns (ToggleUserClubFeatureResponse){ - option (google.api.http) = { - post: "/ClubFeature/ToggleFeature" - body: "*" - }; -}; -``` - -**Message Definitions:** -```protobuf -message GetUserClubFeaturesRequest { - int64 user_id = 1; -} - -message GetUserClubFeaturesResponse { - repeated UserClubFeatureModel features = 1; -} - -message UserClubFeatureModel { - int64 id = 1; - int64 user_id = 2; - int64 club_membership_id = 3; - int64 club_feature_id = 4; - string feature_title = 5; - string feature_description = 6; - bool is_active = 7; - google.protobuf.Timestamp granted_at = 8; - string notes = 9; -} - -message ToggleUserClubFeatureRequest { - int64 user_id = 1; - int64 club_feature_id = 2; - bool is_active = 3; -} - -message ToggleUserClubFeatureResponse { - bool success = 1; - string message = 2; - google.protobuf.Int64Value user_club_feature_id = 3; - google.protobuf.BoolValue is_active = 4; -} -``` - ---- - -#### gRPC Service Implementation -**File:** `/CMS/src/CMSMicroservice.WebApi/Services/ClubMembershipService.cs` - -**Added Methods:** -```csharp -public override async Task GetUserClubFeatures( - GetUserClubFeaturesRequest request, - ServerCallContext context) -{ - return await _dispatchRequestToCQRS.Handle< - GetUserClubFeaturesRequest, - GetUserClubFeaturesQuery, - GetUserClubFeaturesResponse>(request, context); -} - -public override async Task - ToggleUserClubFeature( - ToggleUserClubFeatureRequest request, - ServerCallContext context) -{ - return await _dispatchRequestToCQRS.Handle< - ToggleUserClubFeatureRequest, - ToggleUserClubFeatureCommand, - Protobuf.Protos.ClubMembership.ToggleUserClubFeatureResponse>(request, context); -} -``` - ---- - -#### AutoMapper Profile -**File:** `/CMS/src/CMSMicroservice.WebApi/Common/Mappings/ClubFeatureProfile.cs` - -**Mappings:** -1. `GetUserClubFeaturesRequest` → `GetUserClubFeaturesQuery` -2. `UserClubFeatureDto` → `UserClubFeatureModel` (Proto) -3. `List` → `GetUserClubFeaturesResponse` -4. `ToggleUserClubFeatureRequest` → `ToggleUserClubFeatureCommand` -5. `ToggleUserClubFeatureResponse` (App) → `ToggleUserClubFeatureResponse` (Proto) - -**Special Handling:** -- DateTime conversion to `Timestamp` (Protobuf format) -- Null-safe mapping for optional fields -- Fully qualified type names to avoid ambiguity - ---- - -## API Endpoints - -### 1. Get User Club Features -**Method:** GET -**Endpoint:** `/ClubFeature/GetUserFeatures` -**Request:** -```json -{ - "user_id": 123 -} -``` - -**Response:** -```json -{ - "features": [ - { - "id": 1, - "user_id": 123, - "club_membership_id": 456, - "club_feature_id": 1, - "feature_title": "دسترسی به فروشگاه تخفیف", - "feature_description": "امکان خرید از فروشگاه تخفیف", - "is_active": true, - "granted_at": "2025-12-09T18:30:00Z", - "notes": "اعطا شده به‌طور خودکار هنگام فعالسازی" - } - ] -} -``` - ---- - -### 2. Toggle User Club Feature -**Method:** POST -**Endpoint:** `/ClubFeature/ToggleFeature` -**Request:** -```json -{ - "user_id": 123, - "club_feature_id": 1, - "is_active": false -} -``` - -**Response (Success):** -```json -{ - "success": true, - "message": "ویژگی با موفقیت غیرفعال شد", - "user_club_feature_id": 1, - "is_active": false -} -``` - -**Response (Error - User Not Found):** -```json -{ - "success": false, - "message": "کاربر یافت نشد" -} -``` - -**Response (Error - Feature Not Found):** -```json -{ - "success": false, - "message": "ویژگی باشگاه یافت نشد" -} -``` - -**Response (Error - User Doesn't Have Feature):** -```json -{ - "success": false, - "message": "این ویژگی برای کاربر یافت نشد" -} -``` - ---- - -## Database Schema - -### Table: UserClubFeatures -Existing table with newly added `IsActive` field: - -```sql -CREATE TABLE [CMS].[UserClubFeatures] -( - [Id] BIGINT IDENTITY(1,1) PRIMARY KEY, - [UserId] BIGINT NOT NULL, - [ClubMembershipId] BIGINT NOT NULL, - [ClubFeatureId] BIGINT NOT NULL, - [GrantedAt] DATETIME2 NOT NULL, - [IsActive] BIT NOT NULL DEFAULT 1, -- ← NEW FIELD - [Notes] NVARCHAR(MAX) NULL, - [Created] DATETIME2 NOT NULL, - [CreatedBy] NVARCHAR(MAX) NULL, - [LastModified] DATETIME2 NULL, - [LastModifiedBy] NVARCHAR(MAX) NULL, - [IsDeleted] BIT NOT NULL DEFAULT 0, - - CONSTRAINT FK_UserClubFeatures_Users FOREIGN KEY ([UserId]) - REFERENCES [Identity].[Users]([Id]), - CONSTRAINT FK_UserClubFeatures_ClubMembership FOREIGN KEY ([ClubMembershipId]) - REFERENCES [CMS].[ClubMembership]([Id]), - CONSTRAINT FK_UserClubFeatures_ClubFeatures FOREIGN KEY ([ClubFeatureId]) - REFERENCES [CMS].[ClubFeatures]([Id]) -); -``` - ---- - -## Usage Examples - -### Admin Panel Scenario - -#### 1. View User's Club Features -```csharp -// Admin selects user ID: 123 -var request = new GetUserClubFeaturesRequest { UserId = 123 }; -var response = await client.GetUserClubFeaturesAsync(request); - -// Display in grid: -foreach (var feature in response.Features) -{ - Console.WriteLine($"Feature: {feature.FeatureTitle}"); - Console.WriteLine($"Status: {(feature.IsActive ? "فعال" : "غیرفعال")}"); - Console.WriteLine($"Granted: {feature.GrantedAt}"); - Console.WriteLine("---"); -} -``` - -**Output:** -``` -Feature: دسترسی به فروشگاه تخفیف -Status: فعال -Granted: 2025-12-09 18:30:00 ---- -Feature: دسترسی به کمیسیون هفتگی -Status: فعال -Granted: 2025-12-09 18:30:00 ---- -Feature: دسترسی به شارژ شبکه -Status: غیرفعال -Granted: 2025-12-09 18:30:00 ---- -``` - ---- - -#### 2. Disable a Feature -```csharp -// Admin clicks "Disable" on Feature ID: 3 -var request = new ToggleUserClubFeatureRequest -{ - UserId = 123, - ClubFeatureId = 3, - IsActive = false -}; - -var response = await client.ToggleUserClubFeatureAsync(request); - -if (response.Success) -{ - Console.WriteLine(response.Message); - // Output: ویژگی با موفقیت غیرفعال شد -} -``` - ---- - -#### 3. Re-enable a Feature -```csharp -// Admin clicks "Enable" on Feature ID: 3 -var request = new ToggleUserClubFeatureRequest -{ - UserId = 123, - ClubFeatureId = 3, - IsActive = true -}; - -var response = await client.ToggleUserClubFeatureAsync(request); - -if (response.Success) -{ - Console.WriteLine(response.Message); - // Output: ویژگی با موفقیت فعال شد -} -``` - ---- - -## Testing Checklist - -### Unit Tests (Recommended) -- [ ] GetUserClubFeaturesQueryHandler returns correct DTOs -- [ ] ToggleUserClubFeatureCommandHandler validates user exists -- [ ] ToggleUserClubFeatureCommandHandler validates feature exists -- [ ] ToggleUserClubFeatureCommandHandler validates user has feature -- [ ] ToggleUserClubFeatureCommandHandler updates IsActive correctly -- [ ] ToggleUserClubFeatureCommandHandler sets LastModified timestamp - -### Integration Tests -- [ ] gRPC GetUserClubFeatures endpoint returns data -- [ ] gRPC ToggleUserClubFeature endpoint updates database -- [ ] AutoMapper mappings work correctly -- [ ] Proto serialization/deserialization works - -### Manual Testing -1. **Get Features:** - ```bash - grpcurl -d '{"user_id": 123}' \ - -plaintext localhost:5000 \ - clubmembership.ClubMembershipContract/GetUserClubFeatures - ``` - -2. **Disable Feature:** - ```bash - grpcurl -d '{"user_id": 123, "club_feature_id": 1, "is_active": false}' \ - -plaintext localhost:5000 \ - clubmembership.ClubMembershipContract/ToggleUserClubFeature - ``` - -3. **Verify in Database:** - ```sql - SELECT Id, UserId, ClubFeatureId, IsActive, LastModified - FROM CMS.UserClubFeatures - WHERE UserId = 123; - ``` - ---- - -## Build Status -✅ **All projects build successfully** -- CMSMicroservice.Domain: ✅ -- CMSMicroservice.Application: ✅ (0 errors, 274 warnings) -- CMSMicroservice.Protobuf: ✅ -- CMSMicroservice.WebApi: ✅ (0 errors, 17 warnings) - ---- - -## Next Steps (Optional Enhancements) - -1. **Authorization:** - - Add `[Authorize(Roles = "Admin")]` attribute - - Validate admin permissions before toggling - -2. **Audit Logging:** - - Log who changed the feature status - - Track `LastModifiedBy` field - -3. **Bulk Operations:** - - Add endpoint to toggle multiple features at once - - Add endpoint to enable/disable all features for a user - -4. **History Tracking:** - - Create `UserClubFeatureHistory` table - - Log every status change with timestamp and reason - -5. **Notifications:** - - Send notification to user when feature is disabled - - Email/SMS alert for important features - -6. **Business Rules:** - - Add validation: prevent disabling critical features - - Add expiration dates for features - - Add feature dependencies (e.g., Feature B requires Feature A) - ---- - -## Summary -✅ Created CQRS Query + Command for club feature management -✅ Created gRPC Proto definitions and services -✅ Created AutoMapper mappings -✅ All builds successful -✅ Ready for deployment and testing - -**Total Files Created:** 8 -**Total Lines of Code:** ~350 -**Build Errors:** 0 -**Status:** ✅ Complete and ready for use diff --git a/src/CMSMicroservice.Application/Common/Authorization/IPermissionService.cs b/src/CMSMicroservice.Application/Common/Authorization/IPermissionService.cs new file mode 100644 index 0000000..ea0dce7 --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Authorization/IPermissionService.cs @@ -0,0 +1,17 @@ +namespace CMSMicroservice.Application.Common.Authorization; + +/// +/// سرویس بررسی مجوز کاربر بر اساس نقش‌های JWT +/// +public interface IPermissionService +{ + /// + /// دریافت نقش‌های کاربر فعلی از JWT Claims + /// + Task> GetUserRolesAsync(CancellationToken cancellationToken); + + /// + /// بررسی اینکه آیا کاربر فعلی مجوز مشخصی دارد + /// + Task HasPermissionAsync(string permission, CancellationToken cancellationToken); +} diff --git a/src/CMSMicroservice.Application/Common/Authorization/PermissionDefinitions.cs b/src/CMSMicroservice.Application/Common/Authorization/PermissionDefinitions.cs new file mode 100644 index 0000000..95f6991 --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Authorization/PermissionDefinitions.cs @@ -0,0 +1,127 @@ +namespace CMSMicroservice.Application.Common.Authorization; + +/// +/// ثوابت نام مجوزها — دسته‌بندی شده بر اساس حوزه +/// +public static class PermissionNames +{ + // Dashboard + public const string DashboardView = "dashboard.view"; + + // Orders + public const string OrdersView = "orders.view"; + public const string OrdersCreate = "orders.create"; + public const string OrdersUpdate = "orders.update"; + public const string OrdersDelete = "orders.delete"; + public const string OrdersCancel = "orders.cancel"; + public const string OrdersApprove = "orders.approve"; + + // Products + public const string ProductsView = "products.view"; + public const string ProductsCreate = "products.create"; + public const string ProductsUpdate = "products.update"; + public const string ProductsDelete = "products.delete"; + + // Users + public const string UsersView = "users.view"; + public const string UsersUpdate = "users.update"; + public const string UsersDelete = "users.delete"; + + // Commission + public const string CommissionView = "commission.view"; + public const string CommissionApproveWithdrawal = "commission.approve_withdrawal"; + + // Public Messages + public const string PublicMessagesView = "publicmessages.view"; + public const string PublicMessagesCreate = "publicmessages.create"; + public const string PublicMessagesUpdate = "publicmessages.update"; + public const string PublicMessagesPublish = "publicmessages.publish"; + + // Manual Payments + public const string ManualPaymentsView = "manualpayments.view"; + public const string ManualPaymentsCreate = "manualpayments.create"; + public const string ManualPaymentsApprove = "manualpayments.approve"; + + // Settings + public const string SettingsView = "settings.view"; + public const string SettingsUpdate = "settings.update"; + public const string SettingsDelete = "settings.delete"; + public const string SettingsManageConfiguration = "settings.manage_configuration"; + public const string SettingsManageVat = "settings.manage_vat"; + + // Reports + public const string ReportsView = "reports.view"; +} + +/// +/// نام نقش‌ها +/// +public static class RoleNames +{ + public const string SuperAdmin = "Administrator"; + public const string Admin = "Admin"; + public const string Inspector = "Inspector"; +} + +/// +/// تنظیمات نقش→مجوز — ماتریس دسترسی +/// +public static class RolePermissionConfig +{ + private static readonly Dictionary> RolePermissions = new(StringComparer.OrdinalIgnoreCase) + { + [RoleNames.SuperAdmin] = new(StringComparer.OrdinalIgnoreCase) { "*" }, // Full access + + [RoleNames.Admin] = new(StringComparer.OrdinalIgnoreCase) + { + PermissionNames.DashboardView, + PermissionNames.OrdersView, + PermissionNames.OrdersCreate, + PermissionNames.OrdersUpdate, + PermissionNames.OrdersCancel, + PermissionNames.ProductsView, + PermissionNames.ProductsCreate, + PermissionNames.ProductsUpdate, + PermissionNames.ProductsDelete, + PermissionNames.UsersView, + PermissionNames.UsersUpdate, + PermissionNames.CommissionView, + PermissionNames.CommissionApproveWithdrawal, + PermissionNames.PublicMessagesView, + PermissionNames.PublicMessagesCreate, + PermissionNames.PublicMessagesUpdate, + PermissionNames.PublicMessagesPublish, + PermissionNames.ManualPaymentsView, + PermissionNames.ManualPaymentsCreate, + PermissionNames.ReportsView + }, + + [RoleNames.Inspector] = new(StringComparer.OrdinalIgnoreCase) + { + PermissionNames.DashboardView, + PermissionNames.OrdersView, + PermissionNames.UsersView, + PermissionNames.CommissionView, + PermissionNames.PublicMessagesView, + PermissionNames.ReportsView + } + }; + + /// + /// بررسی اینکه آیا نقش مشخصی مجوز خاصی دارد + /// + public static bool HasPermission(string role, string permission) + { + if (string.IsNullOrWhiteSpace(role) || string.IsNullOrWhiteSpace(permission)) + return false; + + if (!RolePermissions.TryGetValue(role, out var permissions)) + return false; + + // Wildcard: SuperAdmin has full access + if (permissions.Contains("*")) + return true; + + return permissions.Contains(permission); + } +} diff --git a/src/CMSMicroservice.Application/Common/Authorization/RequiresPermissionAttribute.cs b/src/CMSMicroservice.Application/Common/Authorization/RequiresPermissionAttribute.cs new file mode 100644 index 0000000..8300108 --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Authorization/RequiresPermissionAttribute.cs @@ -0,0 +1,15 @@ +namespace CMSMicroservice.Application.Common.Authorization; + +/// +/// Attribute برای مشخص کردن مجوز لازم برای دسترسی به یک متد gRPC +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class RequiresPermissionAttribute : Attribute +{ + public RequiresPermissionAttribute(string permission) + { + Permission = permission ?? throw new ArgumentNullException(nameof(permission)); + } + + public string Permission { get; } +} diff --git a/src/CMSMicroservice.Application/Common/Interfaces/IFileManagementService.cs b/src/CMSMicroservice.Application/Common/Interfaces/IFileManagementService.cs new file mode 100644 index 0000000..50b537c --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Interfaces/IFileManagementService.cs @@ -0,0 +1,37 @@ +namespace CMSMicroservice.Application.Common.Interfaces; + +/// +/// Service for uploading files to FMS (File Management Service) +/// +public interface IFileManagementService +{ + /// + /// Uploads a file to FMS and returns the stored file path + /// + /// Target directory path (e.g. "Images/Products") + /// Raw file bytes + /// MIME type (e.g. "image/jpeg") + /// Original file name + /// Cancellation token + /// The stored file path returned by FMS, or null if upload failed + Task UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName, CancellationToken cancellationToken = default); + + /// + /// Uploads an image to FMS with optimization (resize + compress) + /// Returns both main image path and thumbnail path + /// + /// Target directory path (e.g. "Images/Products") + /// Raw image bytes + /// MIME type (e.g. "image/jpeg") + /// Original file name + /// Cancellation token + /// Tuple of (mainImagePath, thumbnailPath), either can be null if upload failed + Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync( + string directory, byte[] fileBytes, string mime, string? fileName, + CancellationToken cancellationToken = default); + + /// + /// Deletes a file from FMS by its ID + /// + Task DeleteFileAsync(long fileId, CancellationToken cancellationToken = default); +} diff --git a/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandHandler.cs b/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandHandler.cs index fc4314f..8cf3ff1 100644 --- a/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandHandler.cs +++ b/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandHandler.cs @@ -57,7 +57,7 @@ public class CreateNewOtpTokenCommandHandler : IRequestHandler { private readonly ILogger _logger; + private readonly IKavenegarService _kavenegarService; - public CreateNewOtpTokenEventHandler(ILogger logger) + public CreateNewOtpTokenEventHandler( + ILogger logger, + IKavenegarService kavenegarService) { _logger = logger; + _kavenegarService = kavenegarService; } - public Task Handle(CreateNewOtpTokenEvent notification, CancellationToken cancellationToken) + public async Task Handle(CreateNewOtpTokenEvent notification, CancellationToken cancellationToken) { - _logger.LogInformation("Domain Event: {DomainEvent}", notification.GetType().Name); + _logger.LogInformation("Domain Event: {DomainEvent} for mobile {Mobile}", + notification.GetType().Name, notification.Item.Mobile); - return Task.CompletedTask; + try + { + await _kavenegarService.VerifyLookupAsync(notification.Item.Mobile, notification.PlainCode); + _logger.LogInformation("OTP SMS sent successfully to {Mobile}", notification.Item.Mobile); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send OTP SMS to {Mobile}", notification.Item.Mobile); + } } } diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageCommand.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageCommand.cs new file mode 100644 index 0000000..eb366c0 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageCommand.cs @@ -0,0 +1,10 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage; + +public record AddProductImageCommand : IRequest +{ + public long ProductId { get; init; } + public string Title { get; init; } = string.Empty; + public byte[]? ImageFileBytes { get; init; } + public string? ImageFileMime { get; init; } + public string? ImageFileName { get; init; } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageCommandHandler.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageCommandHandler.cs new file mode 100644 index 0000000..31f7e78 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageCommandHandler.cs @@ -0,0 +1,86 @@ +using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Domain.Entities; +using Microsoft.Extensions.Logging; + +namespace CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage; + +public class AddProductImageCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IFileManagementService _fileManagementService; + private readonly ILogger _logger; + + public AddProductImageCommandHandler( + IApplicationDbContext context, + IFileManagementService fileManagementService, + ILogger logger) + { + _context = context; + _fileManagementService = fileManagementService; + _logger = logger; + } + + public async Task Handle(AddProductImageCommand request, + CancellationToken cancellationToken) + { + // Verify product exists + var productExists = await _context.Products + .AnyAsync(p => p.Id == request.ProductId, cancellationToken); + if (!productExists) + throw new NotFoundException(nameof(Product), request.ProductId); + + string imagePath = string.Empty; + string thumbnailPath = string.Empty; + + // Upload image to FMS + if (request.ImageFileBytes is { Length: > 0 }) + { + try + { + var (mainPath, thumbPath) = await _fileManagementService.UploadImageWithThumbnailAsync( + "Images/Products/Gallery", + request.ImageFileBytes, + request.ImageFileMime ?? "image/jpeg", + request.ImageFileName, + cancellationToken); + + imagePath = mainPath ?? string.Empty; + thumbnailPath = thumbPath ?? string.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload gallery image to FMS for product {ProductId}", request.ProductId); + } + } + + // Create ProductImage entity + var productImage = new ProductImage + { + Title = request.Title, + ImagePath = imagePath, + ImageThumbnailPath = thumbnailPath + }; + + await _context.ProductImages.AddAsync(productImage, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + + // Create ProductGallery join entity + var productGallery = new ProductGallery + { + ProductId = request.ProductId, + ProductImageId = productImage.Id + }; + + await _context.ProductGalleries.AddAsync(productGallery, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + + return new AddProductImageResponseDto + { + ProductGalleryId = productGallery.Id, + ProductImageId = productImage.Id, + Title = productImage.Title, + ImagePath = productImage.ImagePath, + ImageThumbnailPath = productImage.ImageThumbnailPath + }; + } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageResponseDto.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageResponseDto.cs new file mode 100644 index 0000000..db6245b --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/AddProductImage/AddProductImageResponseDto.cs @@ -0,0 +1,10 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage; + +public class AddProductImageResponseDto +{ + public long ProductGalleryId { get; set; } + public long ProductImageId { get; set; } + public string Title { get; set; } = string.Empty; + public string ImagePath { get; set; } = string.Empty; + public string ImageThumbnailPath { get; set; } = string.Empty; +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommand.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommand.cs new file mode 100644 index 0000000..7cf656a --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommand.cs @@ -0,0 +1,26 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts; + +public record CreateNewProductsCommand : IRequest +{ + public string Title { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string ShortInfomation { get; init; } = string.Empty; + public string FullInformation { get; init; } = string.Empty; + public long Price { get; init; } + public int Discount { get; init; } + public int Rate { get; init; } + public string? ImagePath { get; init; } + public string? ThumbnailPath { get; init; } + public int SaleCount { get; init; } + public int ViewCount { get; init; } + public int RemainingCount { get; init; } + public List CategoryIds { get; init; } = new(); + + // File upload fields (raw bytes from client) + public byte[]? ImageFileBytes { get; init; } + public string? ImageFileMime { get; init; } + public string? ImageFileName { get; init; } + public byte[]? ThumbnailFileBytes { get; init; } + public string? ThumbnailFileMime { get; init; } + public string? ThumbnailFileName { get; init; } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommandHandler.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommandHandler.cs new file mode 100644 index 0000000..6de0876 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommandHandler.cs @@ -0,0 +1,112 @@ +using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Domain.Entities; +using Microsoft.Extensions.Logging; + +namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts; + +public class CreateNewProductsCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IFileManagementService _fileManagementService; + private readonly ILogger _logger; + + public CreateNewProductsCommandHandler( + IApplicationDbContext context, + IFileManagementService fileManagementService, + ILogger logger) + { + _context = context; + _fileManagementService = fileManagementService; + _logger = logger; + } + + public async Task Handle(CreateNewProductsCommand request, + CancellationToken cancellationToken) + { + var entity = new Product + { + Title = request.Title, + Description = request.Description, + ShortInfomation = request.ShortInfomation, + FullInformation = request.FullInformation, + Price = request.Price, + Discount = request.Discount, + Rate = request.Rate, + ImagePath = request.ImagePath ?? string.Empty, + ThumbnailPath = request.ThumbnailPath ?? string.Empty, + SaleCount = request.SaleCount, + ViewCount = request.ViewCount, + RemainingCount = request.RemainingCount + }; + + // Handle image upload to FMS if file bytes provided + if (request.ImageFileBytes is { Length: > 0 }) + { + try + { + var (mainPath, thumbPath) = await _fileManagementService.UploadImageWithThumbnailAsync( + "Images/Products", + request.ImageFileBytes, + request.ImageFileMime ?? "image/jpeg", + request.ImageFileName, + cancellationToken); + + if (!string.IsNullOrWhiteSpace(mainPath)) + entity.ImagePath = mainPath; + + if (!string.IsNullOrWhiteSpace(thumbPath)) + entity.ThumbnailPath = thumbPath; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload product image to FMS"); + } + } + + // Handle separate thumbnail upload if provided (and not already set from main image) + if (request.ThumbnailFileBytes is { Length: > 0 } && string.IsNullOrWhiteSpace(entity.ThumbnailPath)) + { + try + { + var thumbPath = await _fileManagementService.UploadFileAsync( + "Images/Products/Thumbnails", + request.ThumbnailFileBytes, + request.ThumbnailFileMime ?? "image/jpeg", + request.ThumbnailFileName, + cancellationToken); + + if (!string.IsNullOrWhiteSpace(thumbPath)) + entity.ThumbnailPath = thumbPath; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload product thumbnail to FMS"); + } + } + + await _context.Products.AddAsync(entity, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + + // Handle category assignments + if (request.CategoryIds is { Count: > 0 }) + { + foreach (var categoryId in request.CategoryIds) + { + var categoryExists = await _context.Categories + .AnyAsync(c => c.Id == categoryId, cancellationToken); + + if (categoryExists) + { + await _context.ProductCategories.AddAsync(new ProductCategory + { + ProductId = entity.Id, + CategoryId = categoryId + }, cancellationToken); + } + } + await _context.SaveChangesAsync(cancellationToken); + } + + return new CreateNewProductsResponseDto { Id = entity.Id }; + } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommandValidator.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommandValidator.cs new file mode 100644 index 0000000..6ab40b8 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsCommandValidator.cs @@ -0,0 +1,26 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts; + +public class CreateNewProductsCommandValidator : AbstractValidator +{ + public CreateNewProductsCommandValidator() + { + RuleFor(model => model.Title) + .NotEmpty().WithMessage("عنوان محصول الزامی است"); + + RuleFor(model => model.Price) + .GreaterThanOrEqualTo(0).WithMessage("قیمت نمی‌تواند منفی باشد"); + + RuleFor(model => model.Discount) + .InclusiveBetween(0, 100).WithMessage("تخفیف باید بین 0 تا 100 باشد"); + } + + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync( + ValidationContext.CreateWithOptions( + (CreateNewProductsCommand)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) + return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsResponseDto.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsResponseDto.cs new file mode 100644 index 0000000..6c8227c --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/CreateNewProducts/CreateNewProductsResponseDto.cs @@ -0,0 +1,6 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts; + +public class CreateNewProductsResponseDto +{ + public long Id { get; set; } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/DeleteProducts/DeleteProductsCommand.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/DeleteProducts/DeleteProductsCommand.cs new file mode 100644 index 0000000..fe7a46a --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/DeleteProducts/DeleteProductsCommand.cs @@ -0,0 +1,6 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.DeleteProducts; + +public record DeleteProductsCommand : IRequest +{ + public long Id { get; init; } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/DeleteProducts/DeleteProductsCommandHandler.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/DeleteProducts/DeleteProductsCommandHandler.cs new file mode 100644 index 0000000..28bb98c --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/DeleteProducts/DeleteProductsCommandHandler.cs @@ -0,0 +1,38 @@ +using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Domain.Entities; + +namespace CMSMicroservice.Application.ProductsCQ.Commands.DeleteProducts; + +public class DeleteProductsCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public DeleteProductsCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(DeleteProductsCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Products + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken) + ?? throw new NotFoundException(nameof(Product), request.Id); + + // Remove category associations + var productCategories = await _context.ProductCategories + .Where(pc => pc.ProductId == entity.Id) + .ToListAsync(cancellationToken); + _context.ProductCategories.RemoveRange(productCategories); + + // Remove gallery associations + var productGalleries = await _context.ProductGalleries + .Where(pg => pg.ProductId == entity.Id) + .ToListAsync(cancellationToken); + _context.ProductGalleries.RemoveRange(productGalleries); + + _context.Products.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/RemoveProductImage/RemoveProductImageCommand.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/RemoveProductImage/RemoveProductImageCommand.cs new file mode 100644 index 0000000..422b408 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/RemoveProductImage/RemoveProductImageCommand.cs @@ -0,0 +1,6 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.RemoveProductImage; + +public record RemoveProductImageCommand : IRequest +{ + public long ProductGalleryId { get; init; } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/RemoveProductImage/RemoveProductImageCommandHandler.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/RemoveProductImage/RemoveProductImageCommandHandler.cs new file mode 100644 index 0000000..019ba1d --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/RemoveProductImage/RemoveProductImageCommandHandler.cs @@ -0,0 +1,40 @@ +using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Domain.Entities; + +namespace CMSMicroservice.Application.ProductsCQ.Commands.RemoveProductImage; + +public class RemoveProductImageCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public RemoveProductImageCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(RemoveProductImageCommand request, CancellationToken cancellationToken) + { + var gallery = await _context.ProductGalleries + .Include(pg => pg.ProductImage) + .FirstOrDefaultAsync(pg => pg.Id == request.ProductGalleryId, cancellationToken) + ?? throw new NotFoundException(nameof(ProductGallery), request.ProductGalleryId); + + // Remove gallery entry + _context.ProductGalleries.Remove(gallery); + + // Remove the product image if it exists and is not referenced by other galleries + if (gallery.ProductImage != null) + { + var otherReferences = await _context.ProductGalleries + .AnyAsync(pg => pg.ProductImageId == gallery.ProductImageId && pg.Id != gallery.Id, cancellationToken); + + if (!otherReferences) + { + _context.ProductImages.Remove(gallery.ProductImage); + } + } + + await _context.SaveChangesAsync(cancellationToken); + return Unit.Value; + } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommand.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommand.cs new file mode 100644 index 0000000..1a31995 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommand.cs @@ -0,0 +1,27 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts; + +public record UpdateProductsCommand : IRequest +{ + public long Id { get; init; } + public string Title { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string ShortInfomation { get; init; } = string.Empty; + public string FullInformation { get; init; } = string.Empty; + public long Price { get; init; } + public int Discount { get; init; } + public int Rate { get; init; } + public string? ImagePath { get; init; } + public string? ThumbnailPath { get; init; } + public int SaleCount { get; init; } + public int ViewCount { get; init; } + public int RemainingCount { get; init; } + public List CategoryIds { get; init; } = new(); + + // File upload fields (raw bytes from client) + public byte[]? ImageFileBytes { get; init; } + public string? ImageFileMime { get; init; } + public string? ImageFileName { get; init; } + public byte[]? ThumbnailFileBytes { get; init; } + public string? ThumbnailFileMime { get; init; } + public string? ThumbnailFileName { get; init; } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommandHandler.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommandHandler.cs new file mode 100644 index 0000000..8a24baa --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommandHandler.cs @@ -0,0 +1,127 @@ +using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Domain.Entities; +using Microsoft.Extensions.Logging; + +namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts; + +public class UpdateProductsCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IFileManagementService _fileManagementService; + private readonly ILogger _logger; + + public UpdateProductsCommandHandler( + IApplicationDbContext context, + IFileManagementService fileManagementService, + ILogger logger) + { + _context = context; + _fileManagementService = fileManagementService; + _logger = logger; + } + + public async Task Handle(UpdateProductsCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Products + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken) + ?? throw new NotFoundException(nameof(Product), request.Id); + + // Update basic properties + entity.Title = request.Title; + entity.Description = request.Description; + entity.ShortInfomation = request.ShortInfomation; + entity.FullInformation = request.FullInformation; + entity.Price = request.Price; + entity.Discount = request.Discount; + entity.Rate = request.Rate; + entity.SaleCount = request.SaleCount; + entity.ViewCount = request.ViewCount; + entity.RemainingCount = request.RemainingCount; + + // Handle image upload to FMS if new file bytes provided + if (request.ImageFileBytes is { Length: > 0 }) + { + try + { + var (mainPath, thumbPath) = await _fileManagementService.UploadImageWithThumbnailAsync( + "Images/Products", + request.ImageFileBytes, + request.ImageFileMime ?? "image/jpeg", + request.ImageFileName, + cancellationToken); + + if (!string.IsNullOrWhiteSpace(mainPath)) + entity.ImagePath = mainPath; + + if (!string.IsNullOrWhiteSpace(thumbPath)) + entity.ThumbnailPath = thumbPath; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload updated product image to FMS for product {ProductId}", request.Id); + } + } + else + { + // If no new file uploaded, keep existing paths or update from request + if (!string.IsNullOrWhiteSpace(request.ImagePath)) + entity.ImagePath = request.ImagePath; + if (!string.IsNullOrWhiteSpace(request.ThumbnailPath)) + entity.ThumbnailPath = request.ThumbnailPath; + } + + // Handle separate thumbnail upload if provided + if (request.ThumbnailFileBytes is { Length: > 0 }) + { + try + { + var thumbPath = await _fileManagementService.UploadFileAsync( + "Images/Products/Thumbnails", + request.ThumbnailFileBytes, + request.ThumbnailFileMime ?? "image/jpeg", + request.ThumbnailFileName, + cancellationToken); + + if (!string.IsNullOrWhiteSpace(thumbPath)) + entity.ThumbnailPath = thumbPath; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload updated product thumbnail to FMS for product {ProductId}", request.Id); + } + } + + _context.Products.Update(entity); + await _context.SaveChangesAsync(cancellationToken); + + // Update category assignments + if (request.CategoryIds != null) + { + // Remove existing categories + var existingCategories = await _context.ProductCategories + .Where(pc => pc.ProductId == entity.Id) + .ToListAsync(cancellationToken); + + _context.ProductCategories.RemoveRange(existingCategories); + + // Add new categories + foreach (var categoryId in request.CategoryIds) + { + var categoryExists = await _context.Categories + .AnyAsync(c => c.Id == categoryId, cancellationToken); + + if (categoryExists) + { + await _context.ProductCategories.AddAsync(new ProductCategory + { + ProductId = entity.Id, + CategoryId = categoryId + }, cancellationToken); + } + } + await _context.SaveChangesAsync(cancellationToken); + } + + return Unit.Value; + } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommandValidator.cs b/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommandValidator.cs new file mode 100644 index 0000000..12a9ec7 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Commands/UpdateProducts/UpdateProductsCommandValidator.cs @@ -0,0 +1,29 @@ +namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts; + +public class UpdateProductsCommandValidator : AbstractValidator +{ + public UpdateProductsCommandValidator() + { + RuleFor(model => model.Id) + .NotNull().WithMessage("شناسه محصول الزامی است"); + + RuleFor(model => model.Title) + .NotEmpty().WithMessage("عنوان محصول الزامی است"); + + RuleFor(model => model.Price) + .GreaterThanOrEqualTo(0).WithMessage("قیمت نمی‌تواند منفی باشد"); + + RuleFor(model => model.Discount) + .InclusiveBetween(0, 100).WithMessage("تخفیف باید بین 0 تا 100 باشد"); + } + + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync( + ValidationContext.CreateWithOptions( + (UpdateProductsCommand)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) + return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Queries/GetCustomerProducts/GetCustomerProductsQueryHandler.cs b/src/CMSMicroservice.Application/ProductsCQ/Queries/GetCustomerProducts/GetCustomerProductsQueryHandler.cs index 38e944a..6dfe69a 100644 --- a/src/CMSMicroservice.Application/ProductsCQ/Queries/GetCustomerProducts/GetCustomerProductsQueryHandler.cs +++ b/src/CMSMicroservice.Application/ProductsCQ/Queries/GetCustomerProducts/GetCustomerProductsQueryHandler.cs @@ -24,7 +24,7 @@ public class GetCustomerProductsQueryHandler : IRequestHandler +{ + public long ProductId { get; set; } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Queries/GetProductGallery/GetProductGalleryQueryHandler.cs b/src/CMSMicroservice.Application/ProductsCQ/Queries/GetProductGallery/GetProductGalleryQueryHandler.cs new file mode 100644 index 0000000..61acd97 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Queries/GetProductGallery/GetProductGalleryQueryHandler.cs @@ -0,0 +1,34 @@ +using CMSMicroservice.Application.Common.Interfaces; + +namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery; + +public class GetProductGalleryQueryHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public GetProductGalleryQueryHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(GetProductGalleryQuery request, CancellationToken cancellationToken) + { + var galleries = await _context.ProductGalleries + .AsNoTracking() + .Where(pg => pg.ProductId == request.ProductId) + .Include(pg => pg.ProductImage) + .ToListAsync(cancellationToken); + + return new GetProductGalleryResponseDto + { + Items = galleries.Select(pg => new ProductGalleryItemDto + { + ProductGalleryId = pg.Id, + ProductImageId = pg.ProductImageId, + Title = pg.ProductImage?.Title ?? string.Empty, + ImagePath = pg.ProductImage?.ImagePath ?? string.Empty, + ImageThumbnailPath = pg.ProductImage?.ImageThumbnailPath ?? string.Empty + }).ToList() + }; + } +} diff --git a/src/CMSMicroservice.Application/ProductsCQ/Queries/GetProductGallery/GetProductGalleryResponseDto.cs b/src/CMSMicroservice.Application/ProductsCQ/Queries/GetProductGallery/GetProductGalleryResponseDto.cs new file mode 100644 index 0000000..0c8b7f2 --- /dev/null +++ b/src/CMSMicroservice.Application/ProductsCQ/Queries/GetProductGallery/GetProductGalleryResponseDto.cs @@ -0,0 +1,15 @@ +namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery; + +public class GetProductGalleryResponseDto +{ + public List Items { get; set; } = new(); +} + +public class ProductGalleryItemDto +{ + public long ProductGalleryId { get; set; } + public long ProductImageId { get; set; } + public string Title { get; set; } = string.Empty; + public string ImagePath { get; set; } = string.Empty; + public string ImageThumbnailPath { get; set; } = string.Empty; +} diff --git a/src/CMSMicroservice.Application/UserCQ/Commands/AcceptContract/AcceptContractCommandHandler.cs b/src/CMSMicroservice.Application/UserCQ/Commands/AcceptContract/AcceptContractCommandHandler.cs index d0826ff..c352c34 100644 --- a/src/CMSMicroservice.Application/UserCQ/Commands/AcceptContract/AcceptContractCommandHandler.cs +++ b/src/CMSMicroservice.Application/UserCQ/Commands/AcceptContract/AcceptContractCommandHandler.cs @@ -1,5 +1,6 @@ using CMSMicroservice.Application.Common.Interfaces; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using CMSMicroservice.Domain.Entities; namespace CMSMicroservice.Application.UserCQ.Commands.AcceptContract; @@ -7,25 +8,38 @@ public class AcceptContractCommandHandler : IRequestHandler Handle(AcceptContractCommand request, CancellationToken cancellationToken) { // Verify OTP first var otpToken = await _context.OtpTokens - .Where(x => x.Mobile == _currentUserService.Username && x.Purpose == "signContract" && !x.IsUsed && x.Code == request.Code) - .OrderByDescending(x => x.Id) // Use Id instead of CreatedAt for now + .Where(x => x.Mobile == _currentUserService.Username && x.Purpose == "signContract" && !x.IsUsed) + .OrderByDescending(x => x.Id) .FirstOrDefaultAsync(cancellationToken); - if (otpToken == null || !otpToken.IsValid(request.Code)) + var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not set"); + if (otpToken == null || !otpToken.IsValid() || !_hashService.VerifyHmacSha256Hex(request.Code, otpToken.CodeHash, secret)) return new AcceptContractResponseDto { IsSuccess = false, Message = "کد تایید نامعتبر است" }; var user = await _context.Users + .Include(u => u.UserContracts) + .ThenInclude(uc => uc.Contract) + .Include(u => u.UserRoles) + .ThenInclude(ur => ur.Role) + .Include(u => u.ClubMembership) .Where(x => x.Mobile == _currentUserService.Username) .FirstOrDefaultAsync(cancellationToken); @@ -48,12 +62,14 @@ public class AcceptContractCommandHandler : IRequestHandler { private readonly IApplicationDbContext _context; private readonly IGenerateJwtToken _generateJwt; + private readonly IHashService _hashService; + private readonly IConfiguration _cfg; - public VerifyOtpTokenCommandHandler(IApplicationDbContext context, IGenerateJwtToken generateJwt) + public VerifyOtpTokenCommandHandler(IApplicationDbContext context, IGenerateJwtToken generateJwt, + IHashService hashService, IConfiguration cfg) { _context = context; _generateJwt = generateJwt; + _hashService = hashService; + _cfg = cfg; } public async Task Handle(VerifyOtpTokenCommand request, CancellationToken cancellationToken) { var otpToken = await _context.OtpTokens .Where(x => x.Mobile == request.Mobile && x.Purpose == request.Purpose && !x.IsUsed) - .OrderByDescending(x => x.Id) // Use Id instead of CreatedAt for now + .OrderByDescending(x => x.Id) .FirstOrDefaultAsync(cancellationToken); - if (otpToken == null || !otpToken.IsValid(request.Code)) + if (otpToken == null) + return new VerifyOtpTokenResponseDto { Success = false, Message = "کد تایید نامعتبر است" }; + + // Check expiry and usage + if (otpToken.IsUsed || DateTime.Now > otpToken.ExpiresAt) + return new VerifyOtpTokenResponseDto { Success = false, Message = "کد تایید منقضی شده است" }; + + // Verify using the same HMAC-SHA256 method used during creation + var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not set"); + if (!_hashService.VerifyHmacSha256Hex(request.Code, otpToken.CodeHash, secret)) return new VerifyOtpTokenResponseDto { Success = false, Message = "کد تایید نامعتبر است" }; var user = await _context.Users diff --git a/src/CMSMicroservice.Domain/Entities/OtpToken.cs b/src/CMSMicroservice.Domain/Entities/OtpToken.cs index 448c5ec..cfa8268 100644 --- a/src/CMSMicroservice.Domain/Entities/OtpToken.cs +++ b/src/CMSMicroservice.Domain/Entities/OtpToken.cs @@ -18,14 +18,11 @@ public class OtpToken : BaseAuditableEntity public bool IsUsed { get; set; } /// - /// Validates if the provided code is correct and token is not expired + /// Checks if token is still valid (not used and not expired). + /// Code verification should be done in the handler using IHashService. /// - public bool IsValid(string providedCode) + public bool IsValid() { - if (IsUsed || DateTime.UtcNow > ExpiresAt) - return false; - - // Use BCrypt to verify the provided code against the stored hash - return BCrypt.Net.BCrypt.Verify(providedCode, CodeHash); + return !IsUsed && DateTime.Now > ExpiresAt == false; } } diff --git a/src/CMSMicroservice.Domain/Entities/ProductGalleries.cs b/src/CMSMicroservice.Domain/Entities/ProductGalleries.cs deleted file mode 100644 index b1bd0a7..0000000 --- a/src/CMSMicroservice.Domain/Entities/ProductGalleries.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CMSMicroservice.Domain.Entities; -//گالری تصاویر محصول -public class ProductGalleries : BaseAuditableEntity -{ - public long ProductImageId { get; set; } - //ProductImage Navigation Property - public virtual ProductImage ProductImage { get; set; } - public long ProductId { get; set; } - //Product Navigation Property - public virtual Products Product { get; set; } -} diff --git a/src/CMSMicroservice.Domain/Entities/ProductImages.cs b/src/CMSMicroservice.Domain/Entities/ProductImages.cs deleted file mode 100644 index bc56e57..0000000 --- a/src/CMSMicroservice.Domain/Entities/ProductImages.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CMSMicroservice.Domain.Entities; -//توکن Otp -public class ProductImages : BaseAuditableEntity -{ - public string Title { get; set; } - public string ImagePath { get; set; } - public string ImageThumbnailPath { get; set; } - //ProductGalleries Collection Navigation Reference - public virtual ICollection ProductGalleries { get; set; } -} diff --git a/src/CMSMicroservice.Domain/Entities/Products.cs b/src/CMSMicroservice.Domain/Entities/Products.cs deleted file mode 100644 index 24b9d5e..0000000 --- a/src/CMSMicroservice.Domain/Entities/Products.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace CMSMicroservice.Domain.Entities; -//توکن Otp -public class Products : BaseAuditableEntity -{ - public string Title { get; set; } - public string Description { get; set; } - public string ShortInfomation { get; set; } - public string FullInformation { get; set; } - public long Price { get; set; } - public int Discount { get; set; } - public int Rate { get; set; } - public string ImagePath { get; set; } - public string ThumbnailPath { get; set; } - public int SaleCount { get; set; } - public int ViewCount { get; set; } - public int RemainingCount { get; set; } - - // ============= Club Shop Fields ============= - - /// - /// آیا این محصول فقط در فروشگاه باشگاه موجود است - /// - public bool IsClubExclusive { get; set; } - - /// - /// درصد تخفیف باشگاه (0 تا 100) - /// - public int ClubDiscountPercent { get; set; } - - // ============= Navigation Properties ============= - - //UserCarts Collection Navigation Reference - public virtual ICollection UserCarts { get; set; } - //ProductGalleries Collection Navigation Reference - public virtual ICollection ProductGalleries { get; set; } - //FactorDetails Collection Navigation Reference - public virtual ICollection FactorDetails { get; set; } - //ProductCategory Collection Navigation Reference - public virtual ICollection ProductCategories { get; set; } - //ProductTag Collection Navigation Reference - public virtual ICollection ProductTags { get; set; } -} diff --git a/src/CMSMicroservice.Domain/Events/OtpTokenEvents/CreateNewOtpTokenEvent.cs b/src/CMSMicroservice.Domain/Events/OtpTokenEvents/CreateNewOtpTokenEvent.cs index fe088b7..59dfc91 100644 --- a/src/CMSMicroservice.Domain/Events/OtpTokenEvents/CreateNewOtpTokenEvent.cs +++ b/src/CMSMicroservice.Domain/Events/OtpTokenEvents/CreateNewOtpTokenEvent.cs @@ -1,10 +1,15 @@ namespace CMSMicroservice.Domain.Events; public class CreateNewOtpTokenEvent : BaseEvent { - public CreateNewOtpTokenEvent(OtpToken item) + public CreateNewOtpTokenEvent(OtpToken item, string plainCode) { Item = item; + PlainCode = plainCode; } public OtpToken Item { get; } + /// + /// کد OTP به صورت plain text برای ارسال SMS + /// + public string PlainCode { get; } } diff --git a/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj b/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj index 0a33b3b..6522bf9 100644 --- a/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj +++ b/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj @@ -6,6 +6,7 @@ + @@ -18,10 +19,12 @@ + + diff --git a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs index 52ed0eb..e4cfa0f 100644 --- a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs +++ b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs @@ -1,9 +1,11 @@ using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Application.Common.Authorization; using CMSMicroservice.Application.DayaLoanCQ.Services; using CMSMicroservice.Infrastructure.Persistence; using CMSMicroservice.Infrastructure.Persistence.Interceptors; using CMSMicroservice.Infrastructure.BackgroundJobs; using CMSMicroservice.Infrastructure.Services.Monitoring; +using CMSMicroservice.Infrastructure.Services.Authorization; using CMSMicroservice.Infrastructure.Configuration; using CMSMicroservice.Infrastructure.Services.Payment; using CMSMicroservice.Infrastructure.Services.Commission; @@ -35,6 +37,8 @@ public static class ConfigureServices services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Daya Loan API Service - قابل تغییر بین Mock و Real var useMockDayaApi = configuration.GetValue("DayaApi:UseMock", false); diff --git a/src/CMSMicroservice.Infrastructure/Services/Authorization/PermissionService.cs b/src/CMSMicroservice.Infrastructure/Services/Authorization/PermissionService.cs new file mode 100644 index 0000000..a1b731f --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Services/Authorization/PermissionService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Security.Claims; +using CMSMicroservice.Application.Common.Authorization; +using Microsoft.AspNetCore.Http; + +namespace CMSMicroservice.Infrastructure.Services.Authorization; + +/// +/// پیاده‌سازی سرویس مجوز — نقش‌ها از JWT Claims خوانده میشن +/// +public class PermissionService : IPermissionService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public PermissionService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Task> GetUserRolesAsync(CancellationToken cancellationToken) + { + var user = _httpContextAccessor.HttpContext?.User; + if (user?.Identity is not { IsAuthenticated: true }) + return Task.FromResult>(Array.Empty()); + + var roles = user.Claims + .Where(c => c.Type == ClaimTypes.Role + || string.Equals(c.Type, "role", StringComparison.OrdinalIgnoreCase)) + .Select(c => c.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Task.FromResult>(roles); + } + + public async Task HasPermissionAsync(string permission, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(permission)) return true; + + var roles = await GetUserRolesAsync(cancellationToken); + if (roles.Count == 0) return false; + + foreach (var role in roles) + { + if (RolePermissionConfig.HasPermission(role, permission)) + return true; + } + + return false; + } +} diff --git a/src/CMSMicroservice.Infrastructure/Services/FileManagementService.cs b/src/CMSMicroservice.Infrastructure/Services/FileManagementService.cs new file mode 100644 index 0000000..9465ff5 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Services/FileManagementService.cs @@ -0,0 +1,139 @@ +using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Protobuf.Protos.FMS; +using Google.Protobuf; +using Grpc.Net.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; +using System.IO; + +namespace CMSMicroservice.Infrastructure.Services; + +public class FileManagementService : IFileManagementService, IDisposable +{ + private readonly ILogger _logger; + private readonly FileInfoContract.FileInfoContractClient _client; + private readonly GrpcChannel _channel; + + private const int MainImageMaxWidth = 1200; + private const int MainImageMaxHeight = 1200; + private const int ThumbnailMaxWidth = 300; + private const int ThumbnailMaxHeight = 300; + private const int JpegQuality = 75; + + public FileManagementService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + + var fmsAddress = configuration["FMS:Address"] ?? "https://dl.afrino.co"; + + _channel = GrpcChannel.ForAddress(fmsAddress, new GrpcChannelOptions + { + MaxReceiveMessageSize = 100 * 1024 * 1024, // 100 MB + MaxSendMessageSize = 100 * 1024 * 1024 + }); + + _client = new FileInfoContract.FileInfoContractClient(_channel); + } + + public async Task UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName, + CancellationToken cancellationToken = default) + { + try + { + var request = new CreateNewFileInfoRequest + { + Directory = directory, + File = ByteString.CopyFrom(fileBytes), + Mime = mime, + IsBase64 = false + }; + + if (!string.IsNullOrWhiteSpace(fileName)) + request.FileName = fileName; + + var response = await _client.CreateNewFileInfoAsync(request, cancellationToken: cancellationToken); + + if (response != null && !string.IsNullOrWhiteSpace(response.File)) + { + _logger.LogInformation("File uploaded to FMS successfully. Id: {Id}, Path: {Path}", response.Id, response.File); + return response.File; + } + + _logger.LogWarning("FMS upload returned null or empty path for file: {FileName}", fileName); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading file to FMS. Directory: {Directory}, FileName: {FileName}", directory, fileName); + return null; + } + } + + public async Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync( + string directory, byte[] fileBytes, string mime, string? fileName, + CancellationToken cancellationToken = default) + { + string? mainImagePath = null; + string? thumbnailPath = null; + + try + { + // Optimize main image + var mainImageBytes = await OptimizeImageAsync(fileBytes, MainImageMaxWidth, MainImageMaxHeight); + mainImagePath = await UploadFileAsync(directory, mainImageBytes, "image/jpeg", fileName, cancellationToken); + + // Create and upload thumbnail + var thumbnailBytes = await OptimizeImageAsync(fileBytes, ThumbnailMaxWidth, ThumbnailMaxHeight); + var thumbFileName = fileName != null ? $"thumb_{fileName}" : null; + thumbnailPath = await UploadFileAsync($"{directory}/Thumbnails", thumbnailBytes, "image/jpeg", thumbFileName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing and uploading image with thumbnail. Directory: {Directory}", directory); + } + + return (mainImagePath, thumbnailPath); + } + + public async Task DeleteFileAsync(long fileId, CancellationToken cancellationToken = default) + { + try + { + var request = new DeleteFileInfoRequest { Id = fileId }; + var response = await _client.DeleteFileInfoAsync(request, cancellationToken: cancellationToken); + return response?.Success ?? false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting file from FMS. FileId: {FileId}", fileId); + return false; + } + } + + private static async Task OptimizeImageAsync(byte[] imageBytes, int maxWidth, int maxHeight) + { + using var image = Image.Load(imageBytes); + + // Only resize if larger than max dimensions + if (image.Width > maxWidth || image.Height > maxHeight) + { + image.Mutate(x => x.Resize(new ResizeOptions + { + Size = new Size(maxWidth, maxHeight), + Mode = ResizeMode.Max + })); + } + + using var ms = new MemoryStream(); + await image.SaveAsJpegAsync(ms, new JpegEncoder { Quality = JpegQuality }); + return ms.ToArray(); + } + + public void Dispose() + { + _channel?.Dispose(); + } +} diff --git a/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj b/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj index 4c3266f..7e6e219 100644 --- a/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj +++ b/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj @@ -64,6 +64,8 @@ + + diff --git a/src/CMSMicroservice.Protobuf/Protos/discountproduct.proto b/src/CMSMicroservice.Protobuf/Protos/discountproduct.proto index 7edb97c..c549fba 100644 --- a/src/CMSMicroservice.Protobuf/Protos/discountproduct.proto +++ b/src/CMSMicroservice.Protobuf/Protos/discountproduct.proto @@ -87,6 +87,8 @@ message CreateDiscountProductRequest int32 sort_order = 9; bool is_active = 10; repeated int64 category_ids = 11; + ImageFileModel image_file = 12; + ImageFileModel thumbnail_file = 13; } message CreateDiscountProductResponse @@ -108,6 +110,8 @@ message UpdateDiscountProductRequest int32 sort_order = 9; bool is_active = 10; repeated int64 category_ids = 11; + ImageFileModel image_file = 12; + ImageFileModel thumbnail_file = 13; } // Delete Product @@ -246,3 +250,11 @@ message DiscountProductImageDto int32 sort_order = 7; bool is_active = 8; } + +// File upload model for binary image uploads from BackOffice +message ImageFileModel +{ + bytes file = 1; + string mime = 2; + string file_name = 3; +} diff --git a/src/CMSMicroservice.Protobuf/Protos/fms.proto b/src/CMSMicroservice.Protobuf/Protos/fms.proto new file mode 100644 index 0000000..2b6d224 --- /dev/null +++ b/src/CMSMicroservice.Protobuf/Protos/fms.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package fms; + +import "google/protobuf/wrappers.proto"; + +option csharp_namespace = "CMSMicroservice.Protobuf.Protos.FMS"; + +service FileInfoContract +{ + rpc CreateNewFileInfo(CreateNewFileInfoRequest) returns (CreateNewFileInfoResponse); + rpc DeleteFileInfo(DeleteFileInfoRequest) returns (DeleteFileInfoResponse); +} + +message CreateNewFileInfoRequest +{ + string directory = 1; + bytes file = 2; + string mime = 3; + bool is_base64 = 4; + google.protobuf.StringValue file_name = 5; +} + +message CreateNewFileInfoResponse +{ + int64 id = 1; + string file = 2; +} + +message DeleteFileInfoRequest +{ + int64 id = 1; +} + +message DeleteFileInfoResponse +{ + bool success = 1; +} diff --git a/src/CMSMicroservice.Protobuf/Protos/inventory.proto b/src/CMSMicroservice.Protobuf/Protos/inventory.proto index de68f74..37b7878 100644 --- a/src/CMSMicroservice.Protobuf/Protos/inventory.proto +++ b/src/CMSMicroservice.Protobuf/Protos/inventory.proto @@ -270,6 +270,8 @@ message InventoryItemDto { string product_title = 15; int64 product_price = 16; google.protobuf.Timestamp created = 17; + // آیا موجودی کم است (موجودی قابل فروش کمتر از حد هشدار) + bool is_low_stock = 18; } message GetInventoryItemRequest { diff --git a/src/CMSMicroservice.Protobuf/Protos/manualpayment.proto b/src/CMSMicroservice.Protobuf/Protos/manualpayment.proto index 9301b27..150e1ca 100644 --- a/src/CMSMicroservice.Protobuf/Protos/manualpayment.proto +++ b/src/CMSMicroservice.Protobuf/Protos/manualpayment.proto @@ -78,6 +78,7 @@ message CreateManualPaymentRequest google.protobuf.StringValue reference_number = 5; google.protobuf.StringValue image_path = 6; google.protobuf.Int64Value image_document_id = 7; + FileUploadModel image_file = 8; } message CreateManualPaymentResponse @@ -155,3 +156,11 @@ message ProcessManualMembershipPaymentResponse string message = 4; } +// File upload model for binary image uploads from BackOffice +message FileUploadModel +{ + bytes file = 1; + string file_name = 2; + string mime = 3; +} + diff --git a/src/CMSMicroservice.Protobuf/Protos/package.proto b/src/CMSMicroservice.Protobuf/Protos/package.proto index cdb9175..2cc189d 100644 --- a/src/CMSMicroservice.Protobuf/Protos/package.proto +++ b/src/CMSMicroservice.Protobuf/Protos/package.proto @@ -113,6 +113,7 @@ message CreateNewPackageRequest string description = 2; string image_path = 3; int64 price = 4; + BoostCardFileModel image_file = 5; } message CreateNewPackageResponse { @@ -125,6 +126,7 @@ message UpdatePackageRequest string description = 3; string image_path = 4; int64 price = 5; + BoostCardFileModel image_file = 6; } message DeletePackageRequest { @@ -412,3 +414,11 @@ enum PaymentStatusEnum PAYMENT_STATUS_FAILED = 2; PAYMENT_STATUS_REFUNDED = 3; } + +// File upload model for binary image uploads from BackOffice +message BoostCardFileModel +{ + bytes file = 1; + string file_name = 2; + string mime = 3; +} diff --git a/src/CMSMicroservice.Protobuf/Protos/products.proto b/src/CMSMicroservice.Protobuf/Protos/products.proto index a50cfdf..553ad78 100644 --- a/src/CMSMicroservice.Protobuf/Protos/products.proto +++ b/src/CMSMicroservice.Protobuf/Protos/products.proto @@ -79,6 +79,50 @@ service ProductsContract get: "/Customer/GetProducts" }; }; + + // ============= Category-Product DragDrop Methods ============= + + rpc GetProductsForCategory(GetProductsForCategoryRequest) returns (GetProductsForCategoryResponse){ + option (google.api.http) = { + get: "/GetProductsForCategory" + }; + }; + rpc GetCategories(GetCategoriesRequest) returns (GetCategoriesResponse){ + option (google.api.http) = { + get: "/GetCategories" + }; + }; + rpc UpdateProductCategories(UpdateProductCategoriesRequest) returns (google.protobuf.Empty){ + option (google.api.http) = { + post: "/UpdateProductCategories" + body: "*" + }; + }; + rpc UpdateCategoryProducts(UpdateCategoryProductsRequest) returns (google.protobuf.Empty){ + option (google.api.http) = { + post: "/UpdateCategoryProducts" + body: "*" + }; + }; + + // ============= Product Image Management ============= + + rpc AddProductImage(AddProductImageRequest) returns (AddProductImageResponse){ + option (google.api.http) = { + post: "/AddProductImage" + body: "*" + }; + }; + rpc RemoveProductImage(RemoveProductImageRequest) returns (google.protobuf.Empty){ + option (google.api.http) = { + delete: "/RemoveProductImage" + }; + }; + rpc GetProductGallery(GetProductGalleryRequest) returns (GetProductGalleryResponse){ + option (google.api.http) = { + get: "/GetProductGallery" + }; + }; } message CreateNewProductsRequest { @@ -96,6 +140,10 @@ message CreateNewProductsRequest int32 remaining_count = 12; // لیست شناسه دسته‌بندی‌های محصول repeated int64 category_ids = 13; + // فایل تصویر اصلی محصول (آپلود باینری) + ImageFileModel image_file = 14; + // فایل تصویر بندانگشتی محصول (آپلود باینری) + ImageFileModel thumbnail_file = 15; } message CreateNewProductsResponse { @@ -118,6 +166,10 @@ message UpdateProductsRequest int32 remaining_count = 13; // لیست شناسه دسته‌بندی‌های محصول repeated int64 category_ids = 14; + // فایل تصویر اصلی محصول (آپلود باینری) + ImageFileModel image_file = 15; + // فایل تصویر بندانگشتی محصول (آپلود باینری) + ImageFileModel thumbnail_file = 16; } message DeleteProductsRequest { @@ -334,3 +386,97 @@ message ToggleProductStatusResponse int32 failed = 3; repeated BulkOperationError errors = 4; } + +// Category Product Item (for drag-drop UI) +message CategoryProductItem +{ + int64 id = 1; + string title = 2; + bool selected = 3; +} + +// Get Products for Category +message GetProductsForCategoryRequest +{ + int64 category_id = 1; +} + +message GetProductsForCategoryResponse +{ + repeated CategoryProductItem items = 1; +} + +// Category Item (for product categories drag-drop) +message CategoryItem +{ + int64 id = 1; + string title = 2; + bool selected = 3; +} + +// Get Categories for Product +message GetCategoriesRequest +{ + int64 product_id = 1; +} + +message GetCategoriesResponse +{ + repeated CategoryItem items = 1; +} + +// Update Product Categories +message UpdateProductCategoriesRequest +{ + int64 product_id = 1; + repeated int64 category_ids = 2; +} + +// Update Category Products +message UpdateCategoryProductsRequest +{ + int64 category_id = 1; + repeated int64 product_ids = 2; +} + +// Image File Model +message ImageFileModel +{ + bytes file = 1; + string mime = 2; + string file_name = 3; +} + +// Get Product Gallery +message GetProductGalleryRequest +{ + int64 product_id = 1; +} + +message GetProductGalleryResponse +{ + repeated ProductGalleryItem items = 1; +} + +// Add Product Image +message AddProductImageRequest +{ + int64 product_id = 1; + string title = 2; + ImageFileModel image_file = 3; +} + +message AddProductImageResponse +{ + int64 product_gallery_id = 1; + int64 product_image_id = 2; + string title = 3; + string image_path = 4; + string image_thumbnail_path = 5; +} + +// Remove Product Image +message RemoveProductImageRequest +{ + int64 product_gallery_id = 1; +} diff --git a/src/CMSMicroservice.Protobuf/Validator/User/VerifyOtpTokenRequestValidator.cs b/src/CMSMicroservice.Protobuf/Validator/User/VerifyOtpTokenRequestValidator.cs new file mode 100644 index 0000000..534e370 --- /dev/null +++ b/src/CMSMicroservice.Protobuf/Validator/User/VerifyOtpTokenRequestValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using CMSMicroservice.Protobuf.Protos.User; +namespace CMSMicroservice.Protobuf.Validator.User; + +public class VerifyOtpTokenRequestValidator : AbstractValidator +{ + public VerifyOtpTokenRequestValidator() + { + RuleFor(model => model.Mobile) + .NotEmpty(); + RuleFor(model => model.Purpose) + .NotEmpty(); + RuleFor(model => model.Code) + .NotEmpty(); + } + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((VerifyOtpTokenRequest)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) + return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} diff --git a/src/CMSMicroservice.WebApi/Interceptors/PermissionInterceptor.cs b/src/CMSMicroservice.WebApi/Interceptors/PermissionInterceptor.cs new file mode 100644 index 0000000..7aa82ce --- /dev/null +++ b/src/CMSMicroservice.WebApi/Interceptors/PermissionInterceptor.cs @@ -0,0 +1,73 @@ +using CMSMicroservice.Application.Common.Authorization; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace CMSMicroservice.WebApi.Interceptors; + +/// +/// gRPC Interceptor برای بررسی مجوز دسترسی +/// بر اساس [RequiresPermission] attribute روی سرویس‌ها/متدها +/// +public class PermissionInterceptor : Interceptor +{ + private readonly IPermissionService _permissionService; + private readonly ILogger _logger; + private readonly IHttpContextAccessor _httpContextAccessor; + + public PermissionInterceptor( + IPermissionService permissionService, + ILogger logger, + IHttpContextAccessor httpContextAccessor) + { + _permissionService = permissionService; + _logger = logger; + _httpContextAccessor = httpContextAccessor; + } + + public override async Task UnaryServerHandler( + TRequest request, ServerCallContext context, + UnaryServerMethod continuation) + { + await EnsureHasPermissionAsync(context); + return await continuation(request, context); + } + + public override async Task ClientStreamingServerHandler( + IAsyncStreamReader requestStream, ServerCallContext context, + ClientStreamingServerMethod continuation) + { + await EnsureHasPermissionAsync(context); + return await continuation(requestStream, context); + } + + private async Task EnsureHasPermissionAsync(ServerCallContext context) + { + var httpContext = context.GetHttpContext() ?? _httpContextAccessor.HttpContext; + if (httpContext == null) return; + + var endpoint = httpContext.GetEndpoint(); + if (endpoint == null) return; + + var permissionAttributes = endpoint.Metadata.GetOrderedMetadata(); + if (permissionAttributes == null || permissionAttributes.Count == 0) return; + + foreach (var attribute in permissionAttributes) + { + var hasPermission = await _permissionService.HasPermissionAsync( + attribute.Permission, httpContext.RequestAborted); + + if (!hasPermission) + { + _logger.LogWarning( + "Permission denied: {Permission} for method {Method}", + attribute.Permission, context.Method); + + throw new RpcException(new Status( + StatusCode.PermissionDenied, + $"شما مجوز دسترسی به این عملیات را ندارید ({attribute.Permission})")); + } + } + } +} diff --git a/src/CMSMicroservice.WebApi/Program.cs b/src/CMSMicroservice.WebApi/Program.cs index 61bc013..65f51e0 100644 --- a/src/CMSMicroservice.WebApi/Program.cs +++ b/src/CMSMicroservice.WebApi/Program.cs @@ -64,6 +64,7 @@ builder.Services.AddGrpc(options => { options.Interceptors.Add(); options.Interceptors.Add(); + options.Interceptors.Add(); //options.Interceptors.Add(); options.EnableDetailedErrors = true; options.MaxReceiveMessageSize = 1000 * 1024 * 1024; // 1 GB @@ -339,6 +340,7 @@ app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); // Configure the H // Map SignalR Hub for token notifications app.MapHub("/hubs/token-notification"); +app.MapHub("/hubs/token-relay"); // Alias for FrontOffice backward compatibility app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints => { diff --git a/src/CMSMicroservice.WebApi/Services/AppVersionService.cs b/src/CMSMicroservice.WebApi/Services/AppVersionService.cs index b3772b1..f1b1fcf 100644 --- a/src/CMSMicroservice.WebApi/Services/AppVersionService.cs +++ b/src/CMSMicroservice.WebApi/Services/AppVersionService.cs @@ -1,3 +1,4 @@ +using CMSMicroservice.Application.Common.Authorization; using CMSMicroservice.Protobuf.Protos.AppVersion; using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion; @@ -15,16 +16,19 @@ public class AppVersionService : AppVersionContract.AppVersionContractBase _dispatchRequestToCQRS = dispatchRequestToCQRS; } + [RequiresPermission(PermissionNames.SettingsView)] public override async Task GetAppVersion(GetAppVersionRequest request, ServerCallContext context) { return await _dispatchRequestToCQRS.Handle(request, context); } + [RequiresPermission(PermissionNames.SettingsManageConfiguration)] public override async Task UpdateAppVersion(UpdateAppVersionRequest request, ServerCallContext context) { return await _dispatchRequestToCQRS.Handle(request, context); } + [RequiresPermission(PermissionNames.SettingsView)] public override async Task GetAllAppVersions(GetAllAppVersionsRequest request, ServerCallContext context) { return await _dispatchRequestToCQRS.Handle(request, context); diff --git a/src/CMSMicroservice.WebApi/Services/CategoryService.cs b/src/CMSMicroservice.WebApi/Services/CategoryService.cs index dc46a4b..33d9231 100644 --- a/src/CMSMicroservice.WebApi/Services/CategoryService.cs +++ b/src/CMSMicroservice.WebApi/Services/CategoryService.cs @@ -100,7 +100,22 @@ public class CategoryService : CategoryContract.CategoryContractBase public override async Task GetCategoryByIdForCustomer(GetCategoryByIdForCustomerRequest request, ServerCallContext context) { - // TODO: Implement using existing CMS Category Application layer - throw new RpcException(new Status(StatusCode.Unimplemented, "GetCategoryByIdForCustomer not implemented yet")); + var query = new GetCategoryQuery { Id = request.Id }; + var result = await _sender.Send(query, context.CancellationToken); + + return new GetCategoryByIdForCustomerResponse + { + Category = new GetAllCategoryFilterResponseModel + { + Id = result.Id, + Name = result.Name, + Title = result.Title, + Description = result.Description ?? string.Empty, + ImagePath = result.ImagePath ?? string.Empty, + ParentId = result.ParentId ?? 0, + IsActive = result.IsActive, + SortOrder = result.SortOrder + } + }; } } diff --git a/src/CMSMicroservice.WebApi/Services/CityService.cs b/src/CMSMicroservice.WebApi/Services/CityService.cs index 056ed00..4156424 100644 --- a/src/CMSMicroservice.WebApi/Services/CityService.cs +++ b/src/CMSMicroservice.WebApi/Services/CityService.cs @@ -1,16 +1,22 @@ using CMSMicroservice.Protobuf.Protos.City; using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter; +using CMSMicroservice.Application.Common.Interfaces; +using Microsoft.EntityFrameworkCore; +using Google.Protobuf.WellKnownTypes; +using System.Linq; namespace CMSMicroservice.WebApi.Services; public class CityService : CityContract.CityContractBase { private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; + private readonly IApplicationDbContext _context; - public CityService(IDispatchRequestToCQRS dispatchRequestToCQRS) + public CityService(IDispatchRequestToCQRS dispatchRequestToCQRS, IApplicationDbContext context) { _dispatchRequestToCQRS = dispatchRequestToCQRS; + _context = context; } public override async Task GetAllCitiesByFilter( @@ -28,18 +34,50 @@ public class CityService : CityContract.CityContractBase public override async Task GetCitiesForCustomer( GetCitiesForCustomerRequest request, ServerCallContext context) { - // TODO: Implement using existing CMS City Application layer - // For now, return empty response + var pageNumber = request.PageNumber > 0 ? request.PageNumber : 1; + var pageSize = request.PageSize > 0 ? request.PageSize : 50; + + var query = _context.Cities + .Include(c => c.State) + .Where(c => !c.IsDeleted); + + if (request.StateId != null) + query = query.Where(c => c.StateId == request.StateId.Value); + + if (!string.IsNullOrWhiteSpace(request.SearchTerm)) + query = query.Where(c => c.Name.Contains(request.SearchTerm) || c.Native.Contains(request.SearchTerm)); + + var totalCount = await query.CountAsync(context.CancellationToken); + + var cities = await query + .OrderBy(c => c.Native) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .Select(c => new CityDto + { + Id = c.Id, + ExternalId = c.ExternalId, + Name = c.Name, + Native = c.Native, + Latitude = c.Latitude ?? string.Empty, + Longitude = c.Longitude ?? string.Empty, + StateId = c.StateId, + StateName = c.State != null ? c.State.Name : string.Empty, + StateNative = c.State != null ? c.State.Native : string.Empty + }) + .ToListAsync(context.CancellationToken); + return new GetCitiesForCustomerResponse { + Cities = { cities }, MetaData = new CMSMicroservice.Protobuf.Protos.City.MetaData { - CurrentPage = request.PageNumber, - PageSize = request.PageSize, - TotalCount = 0, - TotalPage = 0, - HasNext = false, - HasPrevious = false + CurrentPage = pageNumber, + PageSize = pageSize, + TotalCount = totalCount, + TotalPage = (int)Math.Ceiling(totalCount / (double)pageSize), + HasNext = pageNumber * pageSize < totalCount, + HasPrevious = pageNumber > 1 } }; } @@ -47,34 +85,126 @@ public class CityService : CityContract.CityContractBase public override async Task GetCityByIdForCustomer( GetCityByIdForCustomerRequest request, ServerCallContext context) { - // TODO: Implement using existing CMS City Application layer - throw new RpcException(new Status(StatusCode.Unimplemented, "GetCityByIdForCustomer not implemented yet")); + var city = await _context.Cities + .Include(c => c.State) + .Where(c => c.Id == request.Id && !c.IsDeleted) + .Select(c => new CityDto + { + Id = c.Id, + ExternalId = c.ExternalId, + Name = c.Name, + Native = c.Native, + Latitude = c.Latitude ?? string.Empty, + Longitude = c.Longitude ?? string.Empty, + StateId = c.StateId, + StateName = c.State != null ? c.State.Name : string.Empty, + StateNative = c.State != null ? c.State.Native : string.Empty + }) + .FirstOrDefaultAsync(context.CancellationToken); + + if (city == null) + throw new RpcException(new Status(StatusCode.NotFound, "شهر یافت نشد")); + + return new GetCityByIdForCustomerResponse { City = city }; } public override async Task GetCitiesByStateForCustomer( GetCitiesByStateForCustomerRequest request, ServerCallContext context) { - // TODO: Implement using existing CMS City Application layer - throw new RpcException(new Status(StatusCode.Unimplemented, "GetCitiesByStateForCustomer not implemented yet")); + var pageNumber = request.PageNumber > 0 ? request.PageNumber : 1; + var pageSize = request.PageSize > 0 ? request.PageSize : 100; + + var query = _context.Cities + .Include(c => c.State) + .Where(c => c.StateId == request.StateId && !c.IsDeleted); + + var totalCount = await query.CountAsync(context.CancellationToken); + + var cities = await query + .OrderBy(c => c.Native) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .Select(c => new CityDto + { + Id = c.Id, + ExternalId = c.ExternalId, + Name = c.Name, + Native = c.Native, + Latitude = c.Latitude ?? string.Empty, + Longitude = c.Longitude ?? string.Empty, + StateId = c.StateId, + StateName = c.State != null ? c.State.Name : string.Empty, + StateNative = c.State != null ? c.State.Native : string.Empty + }) + .ToListAsync(context.CancellationToken); + + return new GetCitiesByStateForCustomerResponse + { + Cities = { cities }, + MetaData = new CMSMicroservice.Protobuf.Protos.City.MetaData + { + CurrentPage = pageNumber, + PageSize = pageSize, + TotalCount = totalCount, + TotalPage = (int)Math.Ceiling(totalCount / (double)pageSize), + HasNext = pageNumber * pageSize < totalCount, + HasPrevious = pageNumber > 1 + } + }; } - // Admin Methods placeholder for future expansion + // Admin Methods public override async Task CreateCity( CreateCityRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "CreateCity not implemented yet")); + var city = new Domain.Entities.Geography.City + { + ExternalId = request.ExternalId, + Name = request.Name, + Native = request.Native, + Latitude = request.Latitude ?? string.Empty, + Longitude = request.Longitude ?? string.Empty, + StateId = request.StateId + }; + + _context.Cities.Add(city); + await _context.SaveChangesAsync(context.CancellationToken); + + return new CreateCityResponse + { + Id = city.Id, + Message = "شهر با موفقیت ایجاد شد" + }; } public override async Task UpdateCity( UpdateCityRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "UpdateCity not implemented yet")); + var city = await _context.Cities.FindAsync(new object[] { request.Id }, context.CancellationToken); + if (city == null) + throw new RpcException(new Status(StatusCode.NotFound, "شهر یافت نشد")); + + city.ExternalId = request.ExternalId; + city.Name = request.Name; + city.Native = request.Native; + if (request.Latitude != null) city.Latitude = request.Latitude; + if (request.Longitude != null) city.Longitude = request.Longitude; + city.StateId = request.StateId; + + await _context.SaveChangesAsync(context.CancellationToken); + return new Empty(); } public override async Task DeleteCity( DeleteCityRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "DeleteCity not implemented yet")); + var city = await _context.Cities.FindAsync(new object[] { request.Id }, context.CancellationToken); + if (city == null) + throw new RpcException(new Status(StatusCode.NotFound, "شهر یافت نشد")); + + city.IsDeleted = true; + await _context.SaveChangesAsync(context.CancellationToken); + return new Empty(); } #endregion diff --git a/src/CMSMicroservice.WebApi/Services/ConfigurationService.cs b/src/CMSMicroservice.WebApi/Services/ConfigurationService.cs index 9618bb4..8a096aa 100644 --- a/src/CMSMicroservice.WebApi/Services/ConfigurationService.cs +++ b/src/CMSMicroservice.WebApi/Services/ConfigurationService.cs @@ -1,4 +1,5 @@ using CMSMicroservice.Application.ClubFeatureCQ.Queries.GetUserClubFeatures; +using CMSMicroservice.Application.Common.Authorization; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Domain.Common; using CMSMicroservice.Protobuf.Protos.Configuration; @@ -109,6 +110,7 @@ public class ConfigurationService : ConfigurationContract.ConfigurationContractB /// /// دریافت تمام تنظیمات /// + [RequiresPermission(PermissionNames.SettingsView)] public override Task GetAllConfigurations(GetAllConfigurationsRequest request, ServerCallContext context) { var response = new GetAllConfigurationsResponse(); @@ -145,16 +147,18 @@ public class ConfigurationService : ConfigurationContract.ConfigurationContractB /// /// سایر عملیات‌ها که فعلاً پیاده‌سازی نشده‌اند (چون از constant استفاده می‌کنیم) /// + [RequiresPermission(PermissionNames.SettingsManageConfiguration)] public override Task CreateOrUpdateConfiguration(CreateOrUpdateConfigurationRequest request, ServerCallContext context) { - _logger.LogWarning("CreateOrUpdateConfiguration called but SystemConstants are read-only"); - throw new RpcException(new Status(StatusCode.Unimplemented, "تنظیمات سیستم فقط خواندنی هستند")); + _logger.LogWarning("CreateOrUpdateConfiguration called but SystemConstants are read-only. Key: {Key}", request.Key); + throw new RpcException(new Status(StatusCode.FailedPrecondition, "تنظیمات سیستم فقط خواندنی هستند. تغییر از طریق کد انجام می‌شود")); } + [RequiresPermission(PermissionNames.SettingsManageConfiguration)] public override Task DeactivateConfiguration(DeactivateConfigurationRequest request, ServerCallContext context) { - _logger.LogWarning("DeactivateConfiguration called but SystemConstants are read-only"); - throw new RpcException(new Status(StatusCode.Unimplemented, "تنظیمات سیستم فقط خواندنی هستند")); + _logger.LogWarning("DeactivateConfiguration called but SystemConstants are read-only. Key: {Key}", request.Key); + throw new RpcException(new Status(StatusCode.FailedPrecondition, "تنظیمات سیستم فقط خواندنی هستند. غیرفعال‌سازی از طریق کد انجام می‌شود")); } public override Task GetConfigurationHistory(GetConfigurationHistoryRequest request, ServerCallContext context) diff --git a/src/CMSMicroservice.WebApi/Services/InventoryService.cs b/src/CMSMicroservice.WebApi/Services/InventoryService.cs index 72f206f..63ee6ca 100644 --- a/src/CMSMicroservice.WebApi/Services/InventoryService.cs +++ b/src/CMSMicroservice.WebApi/Services/InventoryService.cs @@ -18,9 +18,12 @@ using CMSMicroservice.Application.InventoryItemCQ.Queries.GetLowStockItems; using CMSMicroservice.Application.StockMovementCQ.Commands.CreateStockMovement; using CMSMicroservice.Application.StockMovementCQ.Queries.GetStockMovements; using CMSMicroservice.Application.StockMovementCQ.Queries.GetStockMovementsByInventoryItem; +using CMSMicroservice.Application.Common.Interfaces; using Google.Protobuf.WellKnownTypes; using Grpc.Core; using MediatR; +using Microsoft.EntityFrameworkCore; +using System.Linq; namespace CMSMicroservice.WebApi.Services; @@ -28,11 +31,13 @@ public class InventoryService : InventoryContract.InventoryContractBase { private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IMediator _mediator; + private readonly IApplicationDbContext _context; - public InventoryService(IDispatchRequestToCQRS dispatchRequestToCQRS, IMediator mediator) + public InventoryService(IDispatchRequestToCQRS dispatchRequestToCQRS, IMediator mediator, IApplicationDbContext context) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _mediator = mediator; + _context = context; } // ========== Warehouse Management ========== @@ -198,28 +203,109 @@ public class InventoryService : InventoryContract.InventoryContractBase } } - public override Task ReserveStock(ReserveStockRequest request, ServerCallContext context) + public override async Task ReserveStock(ReserveStockRequest request, ServerCallContext context) { - // TODO: Implement with product lookup - throw new RpcException(new Status(StatusCode.Unimplemented, "ReserveStock requires product lookup - not yet implemented")); + var inventoryItem = await _mediator.Send( + new GetInventoryByProductQuery + { + ProductId = request.ProductId, + ProductType = (Domain.Enums.ProductType)request.ProductType + }, + context.CancellationToken); + + if (inventoryItem == null) + return new ReserveStockResponse { Success = false, Message = "آیتم موجودی یافت نشد", AvailableQuantity = 0 }; + + var available = inventoryItem.Quantity - inventoryItem.ReservedQuantity; + if (available < request.Quantity) + return new ReserveStockResponse { Success = false, Message = $"موجودی کافی نیست. موجود: {available}", AvailableQuantity = available }; + + await _mediator.Send( + new ReserveInventoryCommand + { + Id = inventoryItem.Id, + Quantity = request.Quantity, + ReferenceNumber = request.OrderId != null ? $"ORDER-{request.OrderId.Value}" : $"RESERVE-{DateTime.UtcNow.Ticks}" + }, + context.CancellationToken); + + return new ReserveStockResponse { Success = true, Message = "رزرو با موفقیت انجام شد", AvailableQuantity = available - request.Quantity }; } - public override Task ReleaseReservation(ReleaseReservationRequest request, ServerCallContext context) + public override async Task ReleaseReservation(ReleaseReservationRequest request, ServerCallContext context) { - // TODO: Implement with product lookup - throw new RpcException(new Status(StatusCode.Unimplemented, "ReleaseReservation requires product lookup - not yet implemented")); + var inventoryItem = await _mediator.Send( + new GetInventoryByProductQuery + { + ProductId = request.ProductId, + ProductType = (Domain.Enums.ProductType)request.ProductType + }, + context.CancellationToken); + + if (inventoryItem == null) + throw new RpcException(new Status(StatusCode.NotFound, "آیتم موجودی یافت نشد")); + + await _mediator.Send( + new ReleaseReservedInventoryCommand + { + Id = inventoryItem.Id, + Quantity = request.Quantity, + ReferenceNumber = request.OrderId != null ? $"RELEASE-ORDER-{request.OrderId.Value}" : $"RELEASE-{DateTime.UtcNow.Ticks}" + }, + context.CancellationToken); + + return new Empty(); } - public override Task ConfirmSale(ConfirmSaleRequest request, ServerCallContext context) + public override async Task ConfirmSale(ConfirmSaleRequest request, ServerCallContext context) { - // TODO: Implement with product lookup - throw new RpcException(new Status(StatusCode.Unimplemented, "ConfirmSale requires product lookup - not yet implemented")); + var inventoryItem = await _mediator.Send( + new GetInventoryByProductQuery + { + ProductId = request.ProductId, + ProductType = (Domain.Enums.ProductType)request.ProductType + }, + context.CancellationToken); + + if (inventoryItem == null) + throw new RpcException(new Status(StatusCode.NotFound, "آیتم موجودی یافت نشد")); + + await _mediator.Send( + new ReduceInventoryCommand + { + Id = inventoryItem.Id, + Quantity = request.Quantity, + FromReserved = request.FromReservation, + ReferenceNumber = request.OrderId != null ? $"SALE-ORDER-{request.OrderId.Value}" : $"SALE-{DateTime.UtcNow.Ticks}" + }, + context.CancellationToken); + + return new Empty(); } - public override Task ProcessReturn(ProcessReturnRequest request, ServerCallContext context) + public override async Task ProcessReturn(ProcessReturnRequest request, ServerCallContext context) { - // TODO: Implement with product lookup - throw new RpcException(new Status(StatusCode.Unimplemented, "ProcessReturn requires product lookup - not yet implemented")); + var inventoryItem = await _mediator.Send( + new GetInventoryByProductQuery + { + ProductId = request.ProductId, + ProductType = (Domain.Enums.ProductType)request.ProductType + }, + context.CancellationToken); + + if (inventoryItem == null) + throw new RpcException(new Status(StatusCode.NotFound, "آیتم موجودی یافت نشد")); + + var result = await _mediator.Send( + new IncreaseInventoryCommand + { + Id = inventoryItem.Id, + Quantity = request.Quantity, + ReferenceNumber = request.OrderId != null ? $"RETURN-ORDER-{request.OrderId.Value}" : $"RETURN-{DateTime.UtcNow.Ticks}" + }, + context.CancellationToken); + + return new ProcessReturnResponse { NewQuantity = result.NewQuantity }; } public override async Task RecordLoss(RecordLossRequest request, ServerCallContext context) @@ -255,10 +341,48 @@ public class InventoryService : InventoryContract.InventoryContractBase // ========== Bulk Operations ========== - public override Task BulkAddStock(BulkAddStockRequest request, ServerCallContext context) + public override async Task BulkAddStock(BulkAddStockRequest request, ServerCallContext context) { - // TODO: Implement with product lookup - throw new RpcException(new Status(StatusCode.Unimplemented, "BulkAddStock requires product lookup - not yet implemented")); + var response = new BulkAddStockResponse(); + + foreach (var item in request.Items) + { + try + { + var inventoryItem = await _mediator.Send( + new GetInventoryByProductQuery + { + ProductId = item.ProductId, + ProductType = (Domain.Enums.ProductType)item.ProductType + }, + context.CancellationToken); + + if (inventoryItem == null) + { + response.FailedCount++; + response.Errors.Add($"ProductId={item.ProductId}: آیتم موجودی یافت نشد"); + continue; + } + + await _mediator.Send( + new IncreaseInventoryCommand + { + Id = inventoryItem.Id, + Quantity = item.Quantity, + ReferenceNumber = request.ReferenceNumber ?? $"BULK-{DateTime.UtcNow.Ticks}" + }, + context.CancellationToken); + + response.SuccessCount++; + } + catch (Exception ex) + { + response.FailedCount++; + response.Errors.Add($"ProductId={item.ProductId}: {ex.Message}"); + } + } + + return response; } // ========== Stock Movements ========== @@ -275,28 +399,74 @@ public class InventoryService : InventoryContract.InventoryContractBase // ========== Reports ========== - public override Task GetInventorySummary(GetInventorySummaryRequest request, ServerCallContext context) + public override async Task GetInventorySummary(GetInventorySummaryRequest request, ServerCallContext context) { - // TODO: Implement summary query - return Task.FromResult(new GetInventorySummaryResponse + var query = _context.InventoryItems + .Include(i => i.Product) + .Include(i => i.DiscountProduct) + .Where(i => !i.IsDeleted); + + if (request.WarehouseId != null) + query = query.Where(i => i.WarehouseId == request.WarehouseId.Value); + + var items = await query.ToListAsync(context.CancellationToken); + + var regularProducts = items.Where(i => i.ProductType == Domain.Enums.ProductType.RegularProduct).ToList(); + var discountProducts = items.Where(i => i.ProductType == Domain.Enums.ProductType.DiscountProduct).ToList(); + + long totalStockValue = items.Sum(i => { - TotalProducts = 0, - TotalDiscountProducts = 0, - TotalQuantity = 0, - TotalReserved = 0, - LowStockCount = 0, - OutOfStockCount = 0, - TotalStockValue = 0 + long unitPrice = i.Product?.Price ?? i.DiscountProduct?.Price ?? 0; + return (long)i.Quantity * unitPrice; }); + + return new GetInventorySummaryResponse + { + TotalProducts = regularProducts.Count, + TotalDiscountProducts = discountProducts.Count, + TotalQuantity = items.Sum(i => i.Quantity), + TotalReserved = items.Sum(i => i.ReservedQuantity), + LowStockCount = items.Count(i => i.Quantity <= i.LowStockThreshold && i.Quantity > 0), + OutOfStockCount = items.Count(i => i.Quantity == 0), + TotalStockValue = totalStockValue + }; } - public override Task GetStockValueReport(GetStockValueReportRequest request, ServerCallContext context) + public override async Task GetStockValueReport(GetStockValueReportRequest request, ServerCallContext context) { - // TODO: Implement stock value report query - return Task.FromResult(new GetStockValueReportResponse + var query = _context.InventoryItems + .Include(i => i.Product) + .Include(i => i.DiscountProduct) + .Where(i => !i.IsDeleted); + + if (request.WarehouseId != null) + query = query.Where(i => i.WarehouseId == request.WarehouseId.Value); + + if (request.ProductType != ProductType.Unspecified) + query = query.Where(i => i.ProductType == (Domain.Enums.ProductType)request.ProductType); + + var dbItems = await query.ToListAsync(context.CancellationToken); + + var items = dbItems.Select(i => { - TotalValue = 0, - TotalItems = 0 - }); + long unitPrice = i.Product?.Price ?? i.DiscountProduct?.Price ?? 0; + string title = i.Product?.Title ?? i.DiscountProduct?.Title ?? string.Empty; + return new StockValueItem + { + ProductId = i.ProductId ?? i.DiscountProductId ?? 0, + ProductTitle = title, + ProductType = (ProductType)i.ProductType, + Quantity = i.Quantity, + UnitPrice = unitPrice, + TotalValue = (long)i.Quantity * unitPrice + }; + }).ToList(); + + return new GetStockValueReportResponse + { + Items = { items }, + TotalValue = items.Sum(i => i.TotalValue), + TotalItems = items.Count + }; } } diff --git a/src/CMSMicroservice.WebApi/Services/ManualPaymentService.cs b/src/CMSMicroservice.WebApi/Services/ManualPaymentService.cs index 098f065..abcdb52 100644 --- a/src/CMSMicroservice.WebApi/Services/ManualPaymentService.cs +++ b/src/CMSMicroservice.WebApi/Services/ManualPaymentService.cs @@ -1,3 +1,4 @@ +using CMSMicroservice.Application.Common.Authorization; using CMSMicroservice.Protobuf.Protos.ManualPayment; using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.Application.ManualPaymentCQ.Commands.CreateManualPayment; @@ -24,6 +25,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB _sender = sender; } + [RequiresPermission(PermissionNames.ManualPaymentsCreate)] public override async Task CreateManualPayment( CreateManualPaymentRequest request, ServerCallContext context) @@ -37,6 +39,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB }; } + [RequiresPermission(PermissionNames.ManualPaymentsApprove)] public override async Task ApproveManualPayment( ApproveManualPaymentRequest request, ServerCallContext context) @@ -44,6 +47,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB return await _dispatchRequestToCQRS.Handle(request, context); } + [RequiresPermission(PermissionNames.ManualPaymentsApprove)] public override async Task RejectManualPayment( RejectManualPaymentRequest request, ServerCallContext context) @@ -51,6 +55,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB return await _dispatchRequestToCQRS.Handle(request, context); } + [RequiresPermission(PermissionNames.ManualPaymentsView)] public override async Task GetAllManualPayments( GetAllManualPaymentsRequest request, ServerCallContext context) @@ -58,6 +63,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB return await _dispatchRequestToCQRS.Handle(request, context); } + [RequiresPermission(PermissionNames.ManualPaymentsCreate)] public override async Task ProcessManualMembershipPayment( ProcessManualMembershipPaymentRequest request, ServerCallContext context) diff --git a/src/CMSMicroservice.WebApi/Services/PackageService.cs b/src/CMSMicroservice.WebApi/Services/PackageService.cs index 8cfc0e7..c4acc9e 100644 --- a/src/CMSMicroservice.WebApi/Services/PackageService.cs +++ b/src/CMSMicroservice.WebApi/Services/PackageService.cs @@ -13,11 +13,14 @@ using CMSMicroservice.Application.PackageCQ.Queries.GetUserPackageStatus; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackages; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackageDetails; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPurchaseHistory; +using CMSMicroservice.Application.Common.Interfaces; using AppModels = CMSMicroservice.Application.Common.Models; using Grpc.Core; using Google.Protobuf.WellKnownTypes; using System.Collections.Generic; +using System.Linq; using CMSMicroservice.Protobuf.Protos; +using Microsoft.EntityFrameworkCore; using MediatR; using Mapster; @@ -26,11 +29,22 @@ public class PackageService : PackageContract.PackageContractBase { private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly ISender _sender; + private readonly IApplicationDbContext _context; + private readonly ICurrentUserService _currentUserService; + private readonly IPaymentGatewayService _paymentGateway; - public PackageService(IDispatchRequestToCQRS dispatchRequestToCQRS, ISender sender) + public PackageService( + IDispatchRequestToCQRS dispatchRequestToCQRS, + ISender sender, + IApplicationDbContext context, + ICurrentUserService currentUserService, + IPaymentGatewayService paymentGateway) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; + _context = context; + _currentUserService = currentUserService; + _paymentGateway = paymentGateway; } public override async Task CreateNewPackage(CreateNewPackageRequest request, ServerCallContext context) { @@ -142,45 +156,161 @@ public class PackageService : PackageContract.PackageContractBase public override async Task CustomerPurchasePackage(CustomerPurchasePackageRequest request, ServerCallContext context) { - // Mock Customer package purchase with realistic Persian response - var orderId = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var authority = "A" + orderId.ToString("D19"); + var userId = GetCurrentUserId(); + + // Lookup package + var package = await _context.Packages + .AsNoTracking() + .Where(p => p.Id == request.PackageId && !p.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (package == null) + throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد")); + + // Create transaction + var transaction = new CMSMicroservice.Domain.Entities.Transaction + { + Amount = package.Price, + Description = $"خرید پکیج {package.Title}", + PaymentStatus = Domain.Enums.PaymentStatus.Pending, + Type = Domain.Enums.TransactionType.Buy + }; + _context.Transactions.Add(transaction); + await _context.SaveChangesAsync(context.CancellationToken); + + // Create purchase record + var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway + ? Domain.Enums.PackagePurchaseMethod.DirectPurchase + : Domain.Enums.PackagePurchaseMethod.DayaLoan; + + var purchase = new CMSMicroservice.Domain.Entities.UserPackagePurchase + { + UserId = userId, + PackageId = package.Id, + PurchaseMethod = purchaseMethod, + PurchasedAt = DateTime.UtcNow, + Amount = package.Price, + TransactionId = transaction.Id + }; + _context.UserPackagePurchases.Add(purchase); + await _context.SaveChangesAsync(context.CancellationToken); + + // Initiate payment with gateway + var user = await _context.Users + .AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => new { u.Mobile }) + .FirstOrDefaultAsync(context.CancellationToken); + + var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest + { + Amount = package.Price, + UserId = userId, + Mobile = user?.Mobile ?? string.Empty, + Description = $"خرید پکیج {package.Title}", + CallbackUrl = request.CallbackUrl + }, context.CancellationToken); + + if (!paymentResult.IsSuccess) + { + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; + await _context.SaveChangesAsync(context.CancellationToken); + + return new CustomerPurchasePackageResponse + { + Success = false, + Message = paymentResult.ErrorMessage ?? "خطا در ارتباط با درگاه پرداخت" + }; + } + + // Save RefId + transaction.RefId = paymentResult.RefId; + await _context.SaveChangesAsync(context.CancellationToken); return new CustomerPurchasePackageResponse { Success = true, Message = "درخواست خرید پکیج با موفقیت ثبت شد", - OrderId = orderId, - PaymentGatewayUrl = $"https://payment.gateway.com/payment?authority={authority}&amount=5600000", - Authority = authority + OrderId = purchase.Id, + PaymentGatewayUrl = paymentResult.GatewayUrl ?? string.Empty, + Authority = paymentResult.RefId ?? string.Empty }; } public override async Task CustomerVerifyPackagePurchase(CustomerVerifyPackagePurchaseRequest request, ServerCallContext context) { - // Mock Customer purchase verification with realistic Persian data - var transactionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var referenceCode = "REF" + transactionId.ToString(); + // Find purchase record + var purchase = await _context.UserPackagePurchases + .Include(p => p.Package) + .Where(p => p.Id == request.OrderId && !p.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); - var isSuccessful = request.Status == "OK"; + if (purchase == null) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + // Find the associated transaction + var transaction = purchase.TransactionId.HasValue + ? await _context.Transactions + .Where(t => t.Id == purchase.TransactionId.Value) + .FirstOrDefaultAsync(context.CancellationToken) + : null; + + // If status from gateway callback is not OK + if (request.Status != "OK") + { + if (transaction != null) + { + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; + await _context.SaveChangesAsync(context.CancellationToken); + } + + return new CustomerVerifyPackagePurchaseResponse + { + Success = false, + Message = "پرداخت توسط کاربر لغو شد" + }; + } + + // Verify with payment gateway + var verifyResult = await _paymentGateway.VerifyPaymentAsync( + request.Authority, request.Status, context.CancellationToken); + + if (verifyResult.IsSuccess && transaction != null) + { + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success; + transaction.PaymentDate = DateTime.UtcNow; + transaction.RefId = verifyResult.RefId; + } + else if (transaction != null) + { + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; + } + + await _context.SaveChangesAsync(context.CancellationToken); return new CustomerVerifyPackagePurchaseResponse { - Success = isSuccessful, - Message = isSuccessful ? "خرید پکیج با موفقیت تایید شد" : "خرید پکیج ناموفق بود", - TransactionId = transactionId, - ReferenceCode = referenceCode, - PurchaseInfo = isSuccessful ? new PackagePurchaseInfo + Success = verifyResult.IsSuccess, + Message = verifyResult.IsSuccess ? "خرید پکیج با موفقیت تایید شد" : (verifyResult.Message ?? "خرید پکیج ناموفق بود"), + TransactionId = transaction?.Id ?? 0, + ReferenceCode = verifyResult.RefId ?? string.Empty, + PurchaseInfo = verifyResult.IsSuccess ? new PackagePurchaseInfo { - PackageId = 1, - PackageName = "پکیج طلایی", - AmountPaid = 5600000, - PurchaseDate = Timestamp.FromDateTime(DateTime.UtcNow), - ExpiryDate = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(365)) + PackageId = purchase.PackageId, + PackageName = purchase.Package?.Title ?? string.Empty, + AmountPaid = purchase.Amount, + PurchaseDate = Timestamp.FromDateTime(DateTime.SpecifyKind(purchase.PurchasedAt, DateTimeKind.Utc)) } : null }; } + private long GetCurrentUserId() + { + if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0) + return userId; + throw new RpcException(new Status(StatusCode.Unauthenticated, "لطفاً وارد حساب کاربری خود شوید")); + } + public override async Task GetCustomerPurchaseHistory(GetCustomerPurchaseHistoryRequest request, ServerCallContext context) { var query = new GetCustomerPurchaseHistoryQuery diff --git a/src/CMSMicroservice.WebApi/Services/ProductsService.cs b/src/CMSMicroservice.WebApi/Services/ProductsService.cs index ba3e971..ce1751d 100644 --- a/src/CMSMicroservice.WebApi/Services/ProductsService.cs +++ b/src/CMSMicroservice.WebApi/Services/ProductsService.cs @@ -1,9 +1,17 @@ using CMSMicroservice.Protobuf.Protos.Products; using Grpc.Core; using MediatR; +using CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts; +using CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts; +using CMSMicroservice.Application.ProductsCQ.Commands.DeleteProducts; +using CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage; +using CMSMicroservice.Application.ProductsCQ.Commands.RemoveProductImage; using CMSMicroservice.Application.ProductsCQ.Queries.GetCustomerProducts; using CMSMicroservice.Application.ProductsCQ.Queries.GetCustomerProductsByFilter; +using CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery; +using CMSMicroservice.Application.Common.Interfaces; using Mapster; +using Microsoft.EntityFrameworkCore; using AppModels = CMSMicroservice.Application.Common.Models; using System.Collections.Generic; using System.Linq; @@ -13,29 +21,142 @@ namespace CMSMicroservice.WebApi.Services; public class ProductsService : ProductsContract.ProductsContractBase { private readonly ISender _sender; + private readonly IApplicationDbContext _context; - public ProductsService(ISender sender) + public ProductsService(ISender sender, IApplicationDbContext context) { _sender = sender; + _context = context; } public override async Task CreateNewProducts(CreateNewProductsRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var command = new CreateNewProductsCommand + { + Title = request.Title, + Description = request.Description, + ShortInfomation = request.ShortInfomation, + FullInformation = request.FullInformation, + Price = request.Price, + Discount = request.Discount, + Rate = request.Rate, + ImagePath = request.ImagePath, + ThumbnailPath = request.ThumbnailPath, + SaleCount = request.SaleCount, + ViewCount = request.ViewCount, + RemainingCount = request.RemainingCount, + CategoryIds = request.CategoryIds?.ToList() ?? new List(), + ImageFileBytes = request.ImageFile?.File?.ToByteArray(), + ImageFileMime = request.ImageFile?.Mime, + ImageFileName = request.ImageFile?.FileName, + ThumbnailFileBytes = request.ThumbnailFile?.File?.ToByteArray(), + ThumbnailFileMime = request.ThumbnailFile?.Mime, + ThumbnailFileName = request.ThumbnailFile?.FileName + }; + + var result = await _sender.Send(command, context.CancellationToken); + return new CreateNewProductsResponse { Id = result.Id }; } public override async Task UpdateProducts(UpdateProductsRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var command = new UpdateProductsCommand + { + Id = request.Id, + Title = request.Title, + Description = request.Description, + ShortInfomation = request.ShortInfomation, + FullInformation = request.FullInformation, + Price = request.Price, + Discount = request.Discount, + Rate = request.Rate, + ImagePath = request.ImagePath, + ThumbnailPath = request.ThumbnailPath, + SaleCount = request.SaleCount, + ViewCount = request.ViewCount, + RemainingCount = request.RemainingCount, + CategoryIds = request.CategoryIds?.ToList() ?? new List(), + ImageFileBytes = request.ImageFile?.File?.ToByteArray(), + ImageFileMime = request.ImageFile?.Mime, + ImageFileName = request.ImageFile?.FileName, + ThumbnailFileBytes = request.ThumbnailFile?.File?.ToByteArray(), + ThumbnailFileMime = request.ThumbnailFile?.Mime, + ThumbnailFileName = request.ThumbnailFile?.FileName + }; + + await _sender.Send(command, context.CancellationToken); + return new Google.Protobuf.WellKnownTypes.Empty(); } public override async Task DeleteProducts(DeleteProductsRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var command = new DeleteProductsCommand { Id = request.Id }; + await _sender.Send(command, context.CancellationToken); + return new Google.Protobuf.WellKnownTypes.Empty(); } public override async Task GetProducts(GetProductsRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var query = new GetCustomerProductsQuery { Id = request.Id }; + var result = await _sender.Send(query, context.CancellationToken); + + var response = new GetProductsResponse + { + Id = result.Id, + Title = result.Title, + Description = result.Description, + ShortInfomation = result.ShortInfomation, + FullInformation = result.FullInformation, + Price = result.Price, + Discount = result.Discount, + Rate = result.Rate, + ImagePath = result.ImagePath, + ThumbnailPath = result.ThumbnailPath, + SaleCount = result.SaleCount, + ViewCount = result.ViewCount, + RemainingCount = result.RemainingCount + }; + + if (result.Gallery != null) + { + foreach (var item in result.Gallery) + { + response.Gallery.Add(new ProductGalleryItem + { + ProductGalleryId = item.ProductGalleryId, + ProductImageId = item.ProductImageId, + Title = item.Title, + ImagePath = item.ImagePath, + ImageThumbnailPath = item.ImageThumbnailPath + }); + } + } + + if (result.Categories != null) + { + foreach (var cat in result.Categories) + { + var categoryPath = new ProductCategoryPath + { + CategoryId = cat.CategoryId, + Title = cat.Title + }; + if (cat.Path != null) + { + foreach (var node in cat.Path) + { + categoryPath.Path.Add(new CategoryNode + { + Id = node.Id, + Title = node.Title, + ParentId = node.ParentId + }); + } + } + response.Categories.Add(categoryPath); + } + } + + return response; } public override async Task GetAllProductsByFilter(GetAllProductsByFilterRequest request, ServerCallContext context) @@ -102,22 +223,256 @@ public class ProductsService : ProductsContract.ProductsContractBase public override async Task BulkUpdateProductPrices(BulkUpdateProductPricesRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var response = new BulkUpdateProductPricesResponse { Total = request.Products.Count }; + + foreach (var item in request.Products) + { + try + { + var product = await _context.Products.FindAsync(new object[] { item.ProductId }, context.CancellationToken); + if (product == null) + { + response.Failed++; + response.Errors.Add(new BulkOperationError { ProductId = item.ProductId, ErrorMessage = "محصول یافت نشد" }); + continue; + } + + product.Price = item.NewPrice; + if (item.NewDiscount != null) product.Discount = item.NewDiscount.Value; + if (item.NewClubDiscountPercent != null) product.ClubDiscountPercent = item.NewClubDiscountPercent.Value; + + response.Succeeded++; + } + catch (Exception ex) + { + response.Failed++; + response.Errors.Add(new BulkOperationError { ProductId = item.ProductId, ErrorMessage = ex.Message }); + } + } + + await _context.SaveChangesAsync(context.CancellationToken); + return response; } public override async Task BulkUpdateProductStock(BulkUpdateProductStockRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var response = new BulkUpdateProductStockResponse { Total = request.Products.Count }; + + foreach (var item in request.Products) + { + try + { + var product = await _context.Products.FindAsync(new object[] { item.ProductId }, context.CancellationToken); + if (product == null) + { + response.Failed++; + response.Errors.Add(new BulkOperationError { ProductId = item.ProductId, ErrorMessage = "محصول یافت نشد" }); + continue; + } + + product.RemainingCount = request.UpdateType switch + { + StockUpdateType.Set => item.Quantity, + StockUpdateType.Add => product.RemainingCount + item.Quantity, + StockUpdateType.Subtract => Math.Max(0, product.RemainingCount - item.Quantity), + _ => item.Quantity + }; + + response.Succeeded++; + } + catch (Exception ex) + { + response.Failed++; + response.Errors.Add(new BulkOperationError { ProductId = item.ProductId, ErrorMessage = ex.Message }); + } + } + + await _context.SaveChangesAsync(context.CancellationToken); + return response; } public override async Task GetLowStockProducts(GetLowStockProductsRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var threshold = request.Threshold > 0 ? request.Threshold : 10; + var pageIndex = request.PageIndex > 0 ? request.PageIndex : 1; + var pageSize = request.PageSize > 0 ? request.PageSize : 20; + + var query = _context.Products + .Where(p => !p.IsDeleted && p.RemainingCount <= threshold); + + if (request.IsClubExclusive != null) + query = query.Where(p => p.IsClubExclusive == request.IsClubExclusive.Value); + + var totalCount = await query.CountAsync(context.CancellationToken); + + var products = await query + .OrderBy(p => p.RemainingCount) + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .Select(p => new LowStockProduct + { + Id = p.Id, + Title = p.Title, + RemainingCount = p.RemainingCount, + Price = p.Price, + IsClubExclusive = p.IsClubExclusive + }) + .ToListAsync(context.CancellationToken); + + return new GetLowStockProductsResponse + { + MetaData = new CMSMicroservice.Protobuf.Protos.MetaData + { + CurrentPage = pageIndex, + PageSize = pageSize, + TotalCount = totalCount, + TotalPage = (int)Math.Ceiling(totalCount / (double)pageSize), + HasPrevious = pageIndex > 1, + HasNext = pageIndex * pageSize < totalCount + }, + Products = { products } + }; } public override async Task ToggleProductStatus(ToggleProductStatusRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var response = new ToggleProductStatusResponse { Total = request.ProductIds.Count }; + + foreach (var productId in request.ProductIds) + { + try + { + var product = await _context.Products.FindAsync(new object[] { productId }, context.CancellationToken); + if (product == null) + { + response.Failed++; + response.Errors.Add(new BulkOperationError { ProductId = productId, ErrorMessage = "محصول یافت نشد" }); + continue; + } + + product.IsDeleted = !request.Enable; + if (request.Enable && request.DefaultStock > 0 && product.RemainingCount == 0) + product.RemainingCount = request.DefaultStock; + + response.Succeeded++; + } + catch (Exception ex) + { + response.Failed++; + response.Errors.Add(new BulkOperationError { ProductId = productId, ErrorMessage = ex.Message }); + } + } + + await _context.SaveChangesAsync(context.CancellationToken); + return response; + } + + // ============= Category-Product DragDrop Methods ============= + + public override async Task GetProductsForCategory(GetProductsForCategoryRequest request, ServerCallContext context) + { + var assignedProductIds = await _context.ProductCategories + .Where(pc => pc.CategoryId == request.CategoryId && !pc.IsDeleted) + .Select(pc => pc.ProductId) + .ToListAsync(context.CancellationToken); + + var allProducts = await _context.Products + .Where(p => !p.IsDeleted) + .OrderBy(p => p.Title) + .Select(p => new CategoryProductItem + { + Id = p.Id, + Title = p.Title, + Selected = assignedProductIds.Contains(p.Id) + }) + .ToListAsync(context.CancellationToken); + + return new GetProductsForCategoryResponse { Items = { allProducts } }; + } + + public override async Task GetCategories(GetCategoriesRequest request, ServerCallContext context) + { + var assignedCategoryIds = await _context.ProductCategories + .Where(pc => pc.ProductId == request.ProductId && !pc.IsDeleted) + .Select(pc => pc.CategoryId) + .ToListAsync(context.CancellationToken); + + var allCategories = await _context.Categories + .Where(c => !c.IsDeleted && c.IsActive) + .OrderBy(c => c.SortOrder) + .Select(c => new CategoryItem + { + Id = c.Id, + Title = c.Title, + Selected = assignedCategoryIds.Contains(c.Id) + }) + .ToListAsync(context.CancellationToken); + + return new GetCategoriesResponse { Items = { allCategories } }; + } + + public override async Task UpdateProductCategories(UpdateProductCategoriesRequest request, ServerCallContext context) + { + var existingLinks = await _context.ProductCategories + .Where(pc => pc.ProductId == request.ProductId) + .ToListAsync(context.CancellationToken); + + // حذف لینک‌های قبلی + foreach (var link in existingLinks) + link.IsDeleted = true; + + // ایجاد لینک‌های جدید + foreach (var categoryId in request.CategoryIds) + { + var existing = existingLinks.FirstOrDefault(l => l.CategoryId == categoryId); + if (existing != null) + { + existing.IsDeleted = false; + } + else + { + _context.ProductCategories.Add(new Domain.Entities.ProductCategory + { + ProductId = request.ProductId, + CategoryId = categoryId + }); + } + } + + await _context.SaveChangesAsync(context.CancellationToken); + return new Google.Protobuf.WellKnownTypes.Empty(); + } + + public override async Task UpdateCategoryProducts(UpdateCategoryProductsRequest request, ServerCallContext context) + { + var existingLinks = await _context.ProductCategories + .Where(pc => pc.CategoryId == request.CategoryId) + .ToListAsync(context.CancellationToken); + + // حذف لینک‌های قبلی + foreach (var link in existingLinks) + link.IsDeleted = true; + + // ایجاد لینک‌های جدید + foreach (var productId in request.ProductIds) + { + var existing = existingLinks.FirstOrDefault(l => l.ProductId == productId); + if (existing != null) + { + existing.IsDeleted = false; + } + else + { + _context.ProductCategories.Add(new Domain.Entities.ProductCategory + { + ProductId = productId, + CategoryId = request.CategoryId + }); + } + } + + await _context.SaveChangesAsync(context.CancellationToken); + return new Google.Protobuf.WellKnownTypes.Empty(); } // ============= Customer-specific Methods ============= @@ -279,4 +634,56 @@ public class ProductsService : ProductsContract.ProductsContractBase return response; } + + // ============= Product Image Management ============= + + public override async Task AddProductImage(AddProductImageRequest request, ServerCallContext context) + { + var command = new AddProductImageCommand + { + ProductId = request.ProductId, + Title = request.Title, + ImageFileBytes = request.ImageFile?.File?.ToByteArray(), + ImageFileMime = request.ImageFile?.Mime, + ImageFileName = request.ImageFile?.FileName + }; + + var result = await _sender.Send(command, context.CancellationToken); + + return new AddProductImageResponse + { + ProductGalleryId = result.ProductGalleryId, + ProductImageId = result.ProductImageId, + Title = result.Title, + ImagePath = result.ImagePath, + ImageThumbnailPath = result.ImageThumbnailPath + }; + } + + public override async Task RemoveProductImage(RemoveProductImageRequest request, ServerCallContext context) + { + var command = new RemoveProductImageCommand { ProductGalleryId = request.ProductGalleryId }; + await _sender.Send(command, context.CancellationToken); + return new Google.Protobuf.WellKnownTypes.Empty(); + } + + public override async Task GetProductGallery(GetProductGalleryRequest request, ServerCallContext context) + { + var query = new GetProductGalleryQuery { ProductId = request.ProductId }; + var result = await _sender.Send(query, context.CancellationToken); + + var response = new GetProductGalleryResponse(); + foreach (var item in result.Items) + { + response.Items.Add(new ProductGalleryItem + { + ProductGalleryId = item.ProductGalleryId, + ProductImageId = item.ProductImageId, + Title = item.Title, + ImagePath = item.ImagePath, + ImageThumbnailPath = item.ImageThumbnailPath + }); + } + return response; + } } diff --git a/src/CMSMicroservice.WebApi/Services/TransactionsService.cs b/src/CMSMicroservice.WebApi/Services/TransactionsService.cs index d5ea70a..28de444 100644 --- a/src/CMSMicroservice.WebApi/Services/TransactionsService.cs +++ b/src/CMSMicroservice.WebApi/Services/TransactionsService.cs @@ -9,20 +9,35 @@ using CMSMicroservice.Application.TransactionsCQ.Commands.VerifyTransaction; using CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransaction; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransactionsByFilter; +using CMSMicroservice.Application.Common.Interfaces; using AppModels = CMSMicroservice.Application.Common.Models; +using Grpc.Core; using MediatR; using Mapster; +using Microsoft.EntityFrameworkCore; +using System.Linq; namespace CMSMicroservice.WebApi.Services; public class TransactionsService : TransactionsContract.TransactionsContractBase { private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly ISender _sender; + private readonly IApplicationDbContext _context; + private readonly ICurrentUserService _currentUserService; + private readonly IPaymentGatewayService _paymentGateway; - public TransactionsService(IDispatchRequestToCQRS dispatchRequestToCQRS, ISender sender) + public TransactionsService( + IDispatchRequestToCQRS dispatchRequestToCQRS, + ISender sender, + IApplicationDbContext context, + ICurrentUserService currentUserService, + IPaymentGatewayService paymentGateway) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; + _context = context; + _currentUserService = currentUserService; + _paymentGateway = paymentGateway; } public override async Task CreateNewTransactions(CreateNewTransactionsRequest request, ServerCallContext context) { @@ -121,26 +136,114 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase public override async Task CustomerPaymentRequest(CustomerPaymentRequestRequest request, ServerCallContext context) { - // Mock payment gateway response + var userId = GetCurrentUserId(); + + // Get user mobile for payment gateway + var user = await _context.Users + .AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => new { u.Mobile, u.Email }) + .FirstOrDefaultAsync(context.CancellationToken); + + // Create transaction record in DB + var transaction = new CMSMicroservice.Domain.Entities.Transaction + { + Amount = request.Amount, + Description = request.Description ?? "پرداخت آنلاین", + PaymentStatus = Domain.Enums.PaymentStatus.Pending, + Type = Domain.Enums.TransactionType.DepositIpg + }; + + _context.Transactions.Add(transaction); + await _context.SaveChangesAsync(context.CancellationToken); + + // Initiate payment with gateway + var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest + { + Amount = request.Amount, + UserId = userId, + Mobile = request.Mobile ?? user?.Mobile ?? string.Empty, + Description = request.Description ?? "پرداخت آنلاین", + CallbackUrl = request.CallbackUrl + }, context.CancellationToken); + + if (!paymentResult.IsSuccess) + { + // Update transaction status to failed + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; + await _context.SaveChangesAsync(context.CancellationToken); + + throw new RpcException(new Status(StatusCode.Internal, + paymentResult.ErrorMessage ?? "خطا در ارتباط با درگاه پرداخت")); + } + + // Save RefId from gateway + transaction.RefId = paymentResult.RefId; + await _context.SaveChangesAsync(context.CancellationToken); + return new CustomerPaymentRequestResponse { - PaymentGWUrl = $"https://payment.gateway.com/payment?amount={request.Amount}&callback={request.CallbackUrl}&description={request.Description}" + PaymentGWUrl = paymentResult.GatewayUrl ?? string.Empty }; } public override async Task CustomerPaymentVerification(CustomerPaymentVerificationRequest request, ServerCallContext context) { - // Mock payment verification response - bool isSuccessful = request.Status == "OK"; + // Find the transaction by authority/refId + var transaction = await _context.Transactions + .Where(t => t.RefId == request.Authority && !t.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (transaction == null) + throw new RpcException(new Status(StatusCode.NotFound, "تراکنش یافت نشد")); + + // If status from gateway callback is not OK, mark as rejected + if (request.Status != "OK") + { + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; + await _context.SaveChangesAsync(context.CancellationToken); + + return new CustomerPaymentVerificationResponse + { + Id = transaction.Id, + PaymentStatus = false, + Message = "پرداخت توسط کاربر لغو شد", + VerificationStatusCode = -1 + }; + } + + // Verify with gateway + var verifyResult = await _paymentGateway.VerifyPaymentAsync( + request.Authority, request.Status, context.CancellationToken); + + if (verifyResult.IsSuccess) + { + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success; + transaction.PaymentDate = DateTime.UtcNow; + transaction.RefId = verifyResult.RefId; + } + else + { + transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; + } + + await _context.SaveChangesAsync(context.CancellationToken); return new CustomerPaymentVerificationResponse { - Id = 12345, - PaymentStatus = isSuccessful, - Message = isSuccessful ? "پرداخت با موفقیت انجام شد" : "پرداخت ناموفق", - RefId = isSuccessful ? "REF123456789" : null, - OrderId = "ORDER001", - VerificationStatusCode = isSuccessful ? 101 : 102 + Id = transaction.Id, + PaymentStatus = verifyResult.IsSuccess, + Message = verifyResult.IsSuccess ? "پرداخت با موفقیت انجام شد" : (verifyResult.Message ?? "پرداخت ناموفق"), + RefId = verifyResult.RefId ?? string.Empty, + OrderId = string.Empty, + VerificationStatusCode = verifyResult.IsSuccess ? 100 : -1 }; } + + private long GetCurrentUserId() + { + if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0) + return userId; + throw new RpcException(new Status(StatusCode.Unauthenticated, "لطفاً وارد حساب کاربری خود شوید")); + } } diff --git a/src/CMSMicroservice.WebApi/Services/UserCartsService.cs b/src/CMSMicroservice.WebApi/Services/UserCartsService.cs index 14669eb..f3804f1 100644 --- a/src/CMSMicroservice.WebApi/Services/UserCartsService.cs +++ b/src/CMSMicroservice.WebApi/Services/UserCartsService.cs @@ -2,10 +2,13 @@ using CMSMicroservice.Application.DiscountShopCQ.Commands.AddToCustomerCart; using CMSMicroservice.Application.DiscountShopCQ.Commands.RemoveFromCustomerCart; using CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateCustomerCartItem; using CMSMicroservice.Application.DiscountShopCQ.Queries.GetCustomerCart; +using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Protobuf.Protos.UserCarts; using CMSMicroservice.WebApi.Common.Services; using Google.Protobuf.WellKnownTypes; +using Microsoft.EntityFrameworkCore; using MediatR; +using System.Linq; namespace CMSMicroservice.WebApi.Services; @@ -13,11 +16,13 @@ public class UserCartsService : UserCartsContract.UserCartsContractBase { private readonly IDispatchRequestToCQRS _dispatcher; private readonly ISender _sender; + private readonly IApplicationDbContext _context; - public UserCartsService(IDispatchRequestToCQRS dispatcher, ISender sender) + public UserCartsService(IDispatchRequestToCQRS dispatcher, ISender sender, IApplicationDbContext context) { _dispatcher = dispatcher; _sender = sender; + _context = context; } #region Customer Methods @@ -117,31 +122,116 @@ public class UserCartsService : UserCartsContract.UserCartsContractBase public override async Task AddNewUserCart( AddNewUserCartRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "AddNewUserCart not implemented yet")); + var entity = new Domain.Entities.UserCart + { + ProductId = request.ProductId, + UserId = request.UserId, + Count = request.Count + }; + + _context.UserCarts.Add(entity); + await _context.SaveChangesAsync(context.CancellationToken); + + return new AddNewUserCartResponse { Id = entity.Id }; } public override async Task UpdateUserCart( UpdateUserCartRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "UpdateUserCart not implemented yet")); + var cartId = request.Id > 0 ? request.Id : request.UserCartId; + var cart = await _context.UserCarts.FindAsync(new object[] { cartId }, context.CancellationToken); + if (cart == null) + throw new RpcException(new Status(StatusCode.NotFound, "آیتم سبد خرید یافت نشد")); + + cart.Count = request.Count; + await _context.SaveChangesAsync(context.CancellationToken); + return new Empty(); } public override async Task DeleteUserCart( DeleteUserCartRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "DeleteUserCart not implemented yet")); + var cart = await _context.UserCarts.FindAsync(new object[] { request.Id }, context.CancellationToken); + if (cart == null) + throw new RpcException(new Status(StatusCode.NotFound, "آیتم سبد خرید یافت نشد")); + + cart.IsDeleted = true; + await _context.SaveChangesAsync(context.CancellationToken); + return new Empty(); } public override async Task GetUserCart( GetUserCartRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "GetUserCart not implemented yet")); + var cart = await _context.UserCarts + .Where(c => c.Id == request.Id && !c.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (cart == null) + throw new RpcException(new Status(StatusCode.NotFound, "آیتم سبد خرید یافت نشد")); + + return new GetUserCartResponse + { + Id = cart.Id, + ProductId = cart.ProductId, + UserId = cart.UserId, + Count = cart.Count + }; } public override async Task GetAllUserCartsByFilter( GetAllUserCartsByFilterRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "GetAllUserCartsByFilter not implemented yet")); + var pageNumber = request.PaginationState?.PageNumber ?? 1; + var pageSize = request.PaginationState?.PageSize ?? 20; + if (pageNumber < 1) pageNumber = 1; + if (pageSize < 1) pageSize = 20; + + var query = _context.UserCarts + .Include(c => c.Product) + .Where(c => !c.IsDeleted); + + if (request.Filter?.Id != null) + query = query.Where(c => c.Id == request.Filter.Id.Value); + if (request.Filter?.ProductId != null) + query = query.Where(c => c.ProductId == request.Filter.ProductId.Value); + if (request.Filter?.UserId != null) + query = query.Where(c => c.UserId == request.Filter.UserId.Value); + + var totalCount = await query.CountAsync(context.CancellationToken); + + var items = await query + .OrderByDescending(c => c.Created) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .Select(c => new GetAllUserCartsByFilterResponseModel + { + Id = c.Id, + ProductId = c.ProductId, + UserId = c.UserId, + Count = c.Count, + ProductTitle = c.Product != null ? c.Product.Title : string.Empty, + ProductShortInfomation = c.Product != null ? (c.Product.ShortInfomation ?? string.Empty) : string.Empty, + ProductPrice = c.Product != null ? c.Product.Price : 0, + ProductDiscount = c.Product != null ? c.Product.Discount : 0, + ProductThumbnailPath = c.Product != null ? (c.Product.ThumbnailPath ?? string.Empty) : string.Empty, + Created = Timestamp.FromDateTime(DateTime.SpecifyKind(c.Created, DateTimeKind.Utc)) + }) + .ToListAsync(context.CancellationToken); + + return new GetAllUserCartsByFilterResponse + { + MetaData = new CMSMicroservice.Protobuf.Protos.MetaData + { + CurrentPage = pageNumber, + PageSize = pageSize, + TotalCount = totalCount, + TotalPage = (int)Math.Ceiling(totalCount / (double)pageSize), + HasPrevious = pageNumber > 1, + HasNext = pageNumber * pageSize < totalCount + }, + Models = { items } + }; } #endregion diff --git a/src/CMSMicroservice.WebApi/Services/UserOrderService.cs b/src/CMSMicroservice.WebApi/Services/UserOrderService.cs index 40af2aa..a9fac9a 100644 --- a/src/CMSMicroservice.WebApi/Services/UserOrderService.cs +++ b/src/CMSMicroservice.WebApi/Services/UserOrderService.cs @@ -1,7 +1,10 @@ +using CMSMicroservice.Application.Common.Authorization; using CMSMicroservice.Protobuf.Protos.UserOrder; using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrders; using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrder; using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrderHistory; +using CMSMicroservice.Application.OrderManagementCQ.Commands.UpdateOrderStatus; +using CMSMicroservice.Application.OrderManagementCQ.Commands.CancelOrderByAdmin; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Domain.Entities; using CMSMicroservice.Domain.Entities.Order; @@ -15,6 +18,7 @@ using CMSMicroservice.Protobuf.Protos; using MediatR; using Mapster; using Microsoft.EntityFrameworkCore; +using System.Linq; namespace CMSMicroservice.WebApi.Services; @@ -30,21 +34,68 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase _context = context; _currentUserService = currentUserService; } + [RequiresPermission(PermissionNames.OrdersCreate)] public override async Task CreateNewUserOrder(CreateNewUserOrderRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var order = new UserOrder + { + Amount = request.Amount, + PackageId = request.PackageId > 0 ? request.PackageId : null, + TransactionId = request.TransactionId, + PaymentStatus = request.HasPaymentStatus + ? (Domain.Enums.PaymentStatus)(int)request.PaymentStatus + : Domain.Enums.PaymentStatus.Pending, + PaymentDate = request.PaymentDate?.ToDateTime(), + UserId = request.UserId, + UserAddressId = request.UserAddressId, + PaymentMethod = request.HasPaymentMethod + ? (Domain.Enums.PaymentMethod)(int)request.PaymentMethod + : null, + DeliveryStatus = Domain.Enums.DeliveryStatus.Pending + }; + + _context.UserOrders.Add(order); + await _context.SaveChangesAsync(context.CancellationToken); + + return new CreateNewUserOrderResponse { Id = order.Id }; } + [RequiresPermission(PermissionNames.OrdersUpdate)] public override async Task UpdateUserOrder(UpdateUserOrderRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var order = await _context.UserOrders.FindAsync(new object[] { request.Id }, context.CancellationToken); + if (order == null) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + if (request.Amount != null) order.Amount = request.Amount.Value; + if (request.PackageId != null) order.PackageId = request.PackageId.Value; + if (request.TransactionId != null) order.TransactionId = request.TransactionId.Value; + if (request.HasPaymentStatus) order.PaymentStatus = (Domain.Enums.PaymentStatus)(int)request.PaymentStatus; + if (request.PaymentDate != null) order.PaymentDate = request.PaymentDate.ToDateTime(); + if (request.UserId != null) order.UserId = request.UserId.Value; + if (request.UserAddressId != null) order.UserAddressId = request.UserAddressId.Value; + if (request.HasPaymentMethod) order.PaymentMethod = (Domain.Enums.PaymentMethod)(int)request.PaymentMethod; + if (request.HasDeliveryStatus) order.DeliveryStatus = (Domain.Enums.DeliveryStatus)(int)request.DeliveryStatus; + if (request.TrackingCode != null) order.TrackingCode = request.TrackingCode; + if (request.DeliveryDescription != null) order.DeliveryDescription = request.DeliveryDescription; + + await _context.SaveChangesAsync(context.CancellationToken); + return new Google.Protobuf.WellKnownTypes.Empty(); } + [RequiresPermission(PermissionNames.OrdersDelete)] public override async Task DeleteUserOrder(DeleteUserOrderRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var order = await _context.UserOrders.FindAsync(new object[] { request.Id }, context.CancellationToken); + if (order == null) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + order.IsDeleted = true; + await _context.SaveChangesAsync(context.CancellationToken); + return new Google.Protobuf.WellKnownTypes.Empty(); } + [RequiresPermission(PermissionNames.OrdersView)] public override async Task GetUserOrder(GetUserOrderRequest request, ServerCallContext context) { var query = new GetCustomerOrderQuery @@ -108,6 +159,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase return response; } + [RequiresPermission(PermissionNames.OrdersView)] public override async Task GetAllUserOrderByFilter(GetAllUserOrderByFilterRequest request, ServerCallContext context) { // Admin API - can view all orders or filter by specific user @@ -342,29 +394,176 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase }; } + [RequiresPermission(PermissionNames.OrdersCancel)] public override async Task CancelOrder(CancelOrderRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var command = new CancelOrderByAdminCommand + { + OrderId = request.OrderId, + CancelReason = request.CancelReason, + RefundToWallet = request.RefundPayment + }; + + await _sender.Send(command, context.CancellationToken); + + return new CancelOrderResponse + { + OrderId = request.OrderId, + Status = (CMSMicroservice.Protobuf.Protos.DeliveryStatus)(int)Domain.Enums.DeliveryStatus.Cancelled, + Message = "سفارش با موفقیت لغو شد", + RefundProcessed = request.RefundPayment + }; } + [RequiresPermission(PermissionNames.OrdersUpdate)] public override async Task UpdateOrderStatus(UpdateOrderStatusRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var order = await _context.UserOrders.FindAsync(new object[] { request.OrderId }, context.CancellationToken); + if (order == null) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + var oldStatus = (int)order.DeliveryStatus; + + var command = new Application.OrderManagementCQ.Commands.UpdateOrderStatus.UpdateOrderStatusCommand + { + OrderId = request.OrderId, + NewStatus = (Domain.Enums.DeliveryStatus)request.NewStatus + }; + + await _sender.Send(command, context.CancellationToken); + + return new UpdateOrderStatusResponse + { + Success = true, + Message = "وضعیت سفارش با موفقیت تغییر کرد", + OldStatus = oldStatus, + NewStatus = request.NewStatus + }; } + [RequiresPermission(PermissionNames.ReportsView)] public override async Task GetOrdersByDateRange(GetOrdersByDateRangeRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var pageNumber = request.PageNumber > 0 ? request.PageNumber : 1; + var pageSize = request.PageSize > 0 ? request.PageSize : 20; + + var query = _context.UserOrders + .Include(o => o.User) + .Include(o => o.FactorDetails) + .Where(o => !o.IsDeleted); + + if (request.StartDate != null) + query = query.Where(o => o.Created >= request.StartDate.ToDateTime()); + if (request.EndDate != null) + query = query.Where(o => o.Created <= request.EndDate.ToDateTime()); + if (request.Status != null) + query = query.Where(o => (int)o.DeliveryStatus == request.Status.Value); + if (request.UserId != null) + query = query.Where(o => o.UserId == request.UserId.Value); + + var totalCount = await query.CountAsync(context.CancellationToken); + + var orders = await query + .OrderByDescending(o => o.Created) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .Select(o => new OrderSummaryDto + { + OrderId = o.Id, + OrderNumber = $"ORD-{o.Id:D6}", + UserId = o.UserId, + UserFullName = o.User != null ? (o.User.FirstName + " " + o.User.LastName) : string.Empty, + TotalAmount = o.Amount, + Status = (int)o.DeliveryStatus, + StatusName = o.DeliveryStatus.ToString(), + ItemsCount = o.FactorDetails.Count, + CreatedAt = Timestamp.FromDateTime(DateTime.SpecifyKind(o.Created, DateTimeKind.Utc)) + }) + .ToListAsync(context.CancellationToken); + + return new GetOrdersByDateRangeResponse + { + MetaData = new CMSMicroservice.Protobuf.Protos.MetaData + { + CurrentPage = pageNumber, + PageSize = pageSize, + TotalCount = totalCount, + TotalPage = (int)Math.Ceiling(totalCount / (double)pageSize), + HasPrevious = pageNumber > 1, + HasNext = pageNumber * pageSize < totalCount + }, + Orders = { orders } + }; } + [RequiresPermission(PermissionNames.OrdersUpdate)] public override async Task ApplyDiscountToOrder(ApplyDiscountToOrderRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var order = await _context.UserOrders.FindAsync(new object[] { request.OrderId }, context.CancellationToken); + if (order == null) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + if (order.PaymentStatus == Domain.Enums.PaymentStatus.Success) + return new ApplyDiscountToOrderResponse + { + Success = false, + Message = "امکان اعمال تخفیف برای سفارش پرداخت شده وجود ندارد" + }; + + var originalAmount = order.Amount; + var discountAmount = Math.Min(request.DiscountAmount, originalAmount); + order.Amount = originalAmount - discountAmount; + + await _context.SaveChangesAsync(context.CancellationToken); + + return new ApplyDiscountToOrderResponse + { + Success = true, + Message = $"تخفیف {discountAmount:N0} تومان با موفقیت اعمال شد", + OriginalAmount = originalAmount, + DiscountAmount = discountAmount, + FinalAmount = order.Amount + }; } + [RequiresPermission(PermissionNames.OrdersView)] public override async Task CalculateOrderPV(CalculateOrderPVRequest request, ServerCallContext context) { - throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); + var order = await _context.UserOrders + .Include(o => o.FactorDetails) + .ThenInclude(fd => fd.Product) + .Where(o => o.Id == request.OrderId && !o.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (order == null) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + var products = new List(); + long totalPV = 0; + + foreach (var fd in order.FactorDetails.Where(f => !f.IsDeleted)) + { + // PV = UnitPrice * Count (simplified - can be customized) + long unitPV = fd.UnitPrice; + long itemPV = unitPV * fd.Count; + totalPV += itemPV; + + products.Add(new ProductPVDto + { + ProductId = fd.ProductId, + ProductTitle = fd.Product?.Title ?? string.Empty, + Quantity = fd.Count, + UnitPv = unitPV, + TotalPv = itemPV + }); + } + + return new CalculateOrderPVResponse + { + OrderId = request.OrderId, + TotalPv = totalPV, + Products = { products } + }; } // ============= Customer-specific Methods ============= @@ -518,13 +717,53 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase public override async Task CustomerCancelOrder(CustomerCancelOrderRequest request, ServerCallContext context) { - // Mock Customer order cancellation with realistic Persian response + var order = await _context.UserOrders + .Where(o => o.Id == request.OrderId && !o.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (order == null) + return new CustomerCancelOrderResponse { Success = false, Message = "سفارش یافت نشد" }; + + if (order.DeliveryStatus != Domain.Enums.DeliveryStatus.Pending) + return new CustomerCancelOrderResponse { Success = false, Message = "فقط سفارش‌های در انتظار قابل لغو هستند" }; + + var refundAmount = order.Amount; + + order.DeliveryStatus = Domain.Enums.DeliveryStatus.Cancelled; + + // Refund to wallet if payment was from wallet + if (order.PaymentStatus == Domain.Enums.PaymentStatus.Success && order.PaymentMethod == Domain.Enums.PaymentMethod.Wallet) + { + var wallet = await _context.UserWallets + .Where(w => w.UserId == order.UserId) + .FirstOrDefaultAsync(context.CancellationToken); + + if (wallet != null) + { + wallet.Balance += refundAmount; + _context.UserWalletChangeLogs.Add(new UserWalletChangeLog + { + WalletId = wallet.Id, + CurrentBalance = wallet.Balance, + ChangeValue = refundAmount, + CurrentNetworkBalance = wallet.NetworkBalance, + ChangeNerworkValue = 0, + CurrentDiscountBalance = wallet.DiscountBalance, + ChangeDiscountValue = 0, + IsIncrease = true, + RefrenceId = order.TransactionId ?? 0 + }); + } + } + + await _context.SaveChangesAsync(context.CancellationToken); + return new CustomerCancelOrderResponse { Success = true, Message = "سفارش شما با موفقیت لغو شد", - RefundAmount = 180000, - RefundTransactionId = "REF" + DateTimeOffset.UtcNow.ToUnixTimeSeconds() + RefundAmount = refundAmount, + RefundTransactionId = $"REF{order.Id}" }; } @@ -574,105 +813,105 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase public override async Task CustomerTrackOrder(CustomerTrackOrderRequest request, ServerCallContext context) { - // Mock Customer order tracking with detailed Persian information - var statusHistory = new List + var order = await _context.UserOrders + .Include(o => o.FactorDetails) + .Include(o => o.Package) + .Where(o => o.Id == request.OrderId && !o.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (order == null) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + var orderModel = new Protobuf.Protos.UserOrder.CustomerOrderModel { - new OrderStatusHistory - { - Status = OrderStatusEnum.OrderStatusPending, - StatusMessage = "در انتظار تایید", - ChangedAt = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-5)), - ChangedBy = "سیستم" - }, - new OrderStatusHistory - { - Status = OrderStatusEnum.OrderStatusConfirmed, - StatusMessage = "تایید شده", - ChangedAt = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-4)), - ChangedBy = "کارشناس فروش" - }, - new OrderStatusHistory - { - Status = OrderStatusEnum.OrderStatusProcessing, - StatusMessage = "در حال آماده‌سازی", - ChangedAt = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-3)), - ChangedBy = "انبار" - }, - new OrderStatusHistory - { - Status = OrderStatusEnum.OrderStatusShipped, - StatusMessage = "ارسال شده", - ChangedAt = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-2)), - ChangedBy = "پست پیشتاز" - } + Id = order.Id, + Amount = order.Amount, + PackageId = order.PackageId ?? 0, + PackageName = order.Package?.Title ?? string.Empty, + Status = (OrderStatusEnum)(int)order.DeliveryStatus, + StatusMessage = GetDeliveryStatusPersian(order.DeliveryStatus), + OrderDate = Timestamp.FromDateTime(DateTime.SpecifyKind(order.Created, DateTimeKind.Utc)), + TrackingCode = order.TrackingCode ?? string.Empty, + ItemsCount = order.FactorDetails.Count(f => !f.IsDeleted), + CanCancel = order.DeliveryStatus == Domain.Enums.DeliveryStatus.Pending, + CanReorder = order.DeliveryStatus == Domain.Enums.DeliveryStatus.Delivered }; - var deliverySteps = new List + var response = new CustomerTrackOrderResponse { - new DeliveryStep - { - StepName = "دریافت از فروشنده", - StepDescription = "بسته از فروشنده دریافت شد", - StepTime = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-2)), - IsCompleted = true - }, - new DeliveryStep - { - StepName = "مرکز پردازش تهران", - StepDescription = "بسته در مرکز پردازش تهران", - StepTime = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-1)), - IsCompleted = true - }, - new DeliveryStep - { - StepName = "در حال ارسال", - StepDescription = "بسته در حال ارسال به آدرس مقصد", - StepTime = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-8)), - IsCompleted = false - } - }; - - return new CustomerTrackOrderResponse - { - Order = new Protobuf.Protos.UserOrder.CustomerOrderModel - { - Id = request.OrderId, - Amount = 180000, - PackageId = 1, - PackageName = "پکیج ویژه", - Status = OrderStatusEnum.OrderStatusShipped, - StatusMessage = "در حال ارسال", - OrderDate = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-5)), - TrackingCode = "TRK" + request.OrderId.ToString("000"), - ItemsCount = 4, - CanCancel = false, - CanReorder = true - }, - StatusHistory = { statusHistory }, + Order = orderModel, DeliveryInfo = new DeliveryTrackingInfo { - TrackingCode = "TRK" + request.OrderId.ToString("000"), + TrackingCode = order.TrackingCode ?? string.Empty, CourierName = "پست پیشتاز", - EstimatedDelivery = "فردا تا ساعت 18:00", - CurrentLocation = "مرکز پخش منطقه 5 تهران", - DeliverySteps = { deliverySteps } + EstimatedDelivery = order.DeliveryDescription ?? string.Empty, + CurrentLocation = string.Empty } }; + + // Add current status to history + response.StatusHistory.Add(new OrderStatusHistory + { + Status = (OrderStatusEnum)(int)order.DeliveryStatus, + StatusMessage = GetDeliveryStatusPersian(order.DeliveryStatus), + ChangedAt = order.LastModified.HasValue + ? Timestamp.FromDateTime(DateTime.SpecifyKind(order.LastModified.Value, DateTimeKind.Utc)) + : Timestamp.FromDateTime(DateTime.SpecifyKind(order.Created, DateTimeKind.Utc)), + ChangedBy = "سیستم" + }); + + return response; } public override async Task CustomerReorderPreviousOrder(CustomerReorderRequest request, ServerCallContext context) { - // Mock Customer reorder functionality - var newOrderId = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var totalAmount = request.UseCurrentPrices ? 280000 : 250000; + var originalOrder = await _context.UserOrders + .Include(o => o.FactorDetails) + .ThenInclude(fd => fd.Product) + .Where(o => o.Id == request.OriginalOrderId && !o.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (originalOrder == null) + return new CustomerReorderResponse { Success = false, Message = "سفارش اصلی یافت نشد" }; + + var userId = originalOrder.UserId; + + // Add items from old order to cart + foreach (var fd in originalOrder.FactorDetails.Where(f => !f.IsDeleted)) + { + var existingCartItem = await _context.UserCarts + .Where(c => c.UserId == userId && c.ProductId == fd.ProductId && !c.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (existingCartItem != null) + { + existingCartItem.Count += fd.Count; + } + else + { + _context.UserCarts.Add(new UserCart + { + UserId = userId, + ProductId = fd.ProductId, + Count = fd.Count + }); + } + } + + await _context.SaveChangesAsync(context.CancellationToken); + + // Calculate total with current prices + long totalAmount = originalOrder.FactorDetails + .Where(f => !f.IsDeleted) + .Sum(fd => request.UseCurrentPrices && fd.Product != null + ? fd.Product.Price * fd.Count + : fd.UnitPrice * fd.Count); return new CustomerReorderResponse { Success = true, - Message = request.UseCurrentPrices ? - "سفارش مجدد با قیمت‌های جدید ثبت شد" : - "سفارش مجدد با قیمت‌های قبلی ثبت شد", - NewOrderId = newOrderId, + Message = "محصولات به سبد خرید اضافه شدند", + NewOrderId = 0, // Cart items added, no order created yet TotalAmount = totalAmount }; } @@ -687,4 +926,17 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase IsEnabled = true }); } + + // ============= Helper Methods ============= + + private static string GetDeliveryStatusPersian(Domain.Enums.DeliveryStatus status) => status switch + { + Domain.Enums.DeliveryStatus.None => "نامشخص", + Domain.Enums.DeliveryStatus.Pending => "در انتظار ارسال", + Domain.Enums.DeliveryStatus.InTransit => "در حال ارسال", + Domain.Enums.DeliveryStatus.Delivered => "تحویل داده شده", + Domain.Enums.DeliveryStatus.Returned => "مرجوع شده", + Domain.Enums.DeliveryStatus.Cancelled => "لغو شده", + _ => status.ToString() + }; } diff --git a/src/CMSMicroservice.WebApi/Services/UserService.cs b/src/CMSMicroservice.WebApi/Services/UserService.cs index 7a4f009..a27f2eb 100644 --- a/src/CMSMicroservice.WebApi/Services/UserService.cs +++ b/src/CMSMicroservice.WebApi/Services/UserService.cs @@ -16,7 +16,10 @@ using CMSMicroservice.Application.UserCQ.Commands.AcceptContract; using CMSMicroservice.Application.UserCQ.Queries.GetCustomerProfile; using CMSMicroservice.Application.UserCQ.Queries.GetCustomerReferrals; using CMSMicroservice.Application.UserCQ.Queries.GetCustomerSettings; +using CMSMicroservice.Application.Common.Interfaces; using Google.Protobuf.WellKnownTypes; +using Microsoft.EntityFrameworkCore; +using Grpc.Core; using System.Collections.Generic; using System.Linq; using MediatR; @@ -28,11 +31,25 @@ public class UserService : UserContract.UserContractBase { private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly ISender _sender; + private readonly IApplicationDbContext _context; + private readonly ICurrentUserService _currentUserService; + private readonly IHashService _hashService; + private readonly IFileManagementService _fileManagementService; - public UserService(IDispatchRequestToCQRS dispatchRequestToCQRS, ISender sender) + public UserService( + IDispatchRequestToCQRS dispatchRequestToCQRS, + ISender sender, + IApplicationDbContext context, + ICurrentUserService currentUserService, + IHashService hashService, + IFileManagementService fileManagementService) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; + _context = context; + _currentUserService = currentUserService; + _hashService = hashService; + _fileManagementService = fileManagementService; } public override async Task CreateNewUser(CreateNewUserRequest request, ServerCallContext context) { @@ -90,33 +107,58 @@ public class UserService : UserContract.UserContractBase public override async Task GetUserForCustomer(GetUserForCustomerRequest request, ServerCallContext context) { - // Mock implementation for Customer Get User - await Task.Delay(10); // Simulate async operation + var userId = GetCurrentUserId(); + + var user = await _context.Users + .AsNoTracking() + .Where(u => u.Id == userId && !u.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (user == null) + throw new RpcException(new Status(StatusCode.NotFound, "کاربر یافت نشد")); return new GetUserForCustomerResponse { - Id = 123, - FirstName = "احمد", - LastName = "محمدی", - Mobile = "09123456789", - Email = "ahmad.mohammadi@example.com", - NationalCode = "1234567890", - AvatarPath = "/avatars/user_123.jpg", - ParentId = 100, - ReferralCode = "REF123456", - IsMobileVerified = true, - MobileVerifiedAt = Timestamp.FromDateTime(DateTime.SpecifyKind(new DateTime(2024, 1, 15), DateTimeKind.Utc)), - EmailNotifications = true, - SmsNotifications = true, - PushNotifications = false, - BirthDate = Timestamp.FromDateTime(DateTime.SpecifyKind(new DateTime(1990, 5, 20), DateTimeKind.Utc)) + Id = user.Id, + FirstName = user.FirstName ?? string.Empty, + LastName = user.LastName ?? string.Empty, + Mobile = user.Mobile, + Email = user.Email ?? string.Empty, + NationalCode = user.NationalCode ?? string.Empty, + AvatarPath = user.AvatarPath ?? string.Empty, + ParentId = user.NetworkParentId, + ReferralCode = user.ReferralCode ?? string.Empty, + IsMobileVerified = user.IsMobileVerified, + MobileVerifiedAt = user.MobileVerifiedAt.HasValue + ? Timestamp.FromDateTime(DateTime.SpecifyKind(user.MobileVerifiedAt.Value, DateTimeKind.Utc)) + : null, + EmailNotifications = user.EmailNotifications, + SmsNotifications = user.SmsNotifications, + PushNotifications = user.PushNotifications, + BirthDate = user.BirthDate.HasValue + ? Timestamp.FromDateTime(DateTime.SpecifyKind(user.BirthDate.Value, DateTimeKind.Utc)) + : null }; } public override async Task UpdateCustomerProfile(UpdateCustomerProfileRequest request, ServerCallContext context) { - // Mock implementation for Update Customer Profile - await Task.Delay(10); + var userId = GetCurrentUserId(); + + var user = await _context.Users + .Where(u => u.Id == userId && !u.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (user == null) + throw new RpcException(new Status(StatusCode.NotFound, "کاربر یافت نشد")); + + if (request.FirstName != null) user.FirstName = request.FirstName; + if (request.LastName != null) user.LastName = request.LastName; + if (request.Email != null) user.Email = request.Email; + if (request.NationalCode != null) user.NationalCode = request.NationalCode; + if (request.BirthDate != null) user.BirthDate = request.BirthDate.ToDateTime(); + + await _context.SaveChangesAsync(context.CancellationToken); return new Empty(); } @@ -153,9 +195,6 @@ public class UserService : UserContract.UserContractBase public override async Task ChangeCustomerPassword(ChangeCustomerPasswordRequest request, ServerCallContext context) { - // Mock implementation for Change Customer Password - await Task.Delay(10); - if (request.NewPassword != request.ConfirmPassword) { return new ChangeCustomerPasswordResponse @@ -174,6 +213,32 @@ public class UserService : UserContract.UserContractBase }; } + var userId = GetCurrentUserId(); + + var user = await _context.Users + .Where(u => u.Id == userId && !u.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (user == null) + throw new RpcException(new Status(StatusCode.NotFound, "کاربر یافت نشد")); + + // Verify current password + if (!string.IsNullOrEmpty(user.HashPassword)) + { + if (!_hashService.VerifyPassword(request.CurrentPassword, user.HashPassword)) + { + return new ChangeCustomerPasswordResponse + { + Success = false, + Message = "رمز عبور فعلی نادرست است" + }; + } + } + + // Hash and save new password + user.HashPassword = _hashService.HashPassword(request.NewPassword); + await _context.SaveChangesAsync(context.CancellationToken); + return new ChangeCustomerPasswordResponse { Success = true, @@ -233,9 +298,6 @@ public class UserService : UserContract.UserContractBase public override async Task UploadCustomerAvatar(UploadCustomerAvatarRequest request, ServerCallContext context) { - // Mock implementation for Upload Customer Avatar - await Task.Delay(10); - if (request.FileData == null || request.FileData.Length == 0) { return new UploadCustomerAvatarResponse @@ -264,10 +326,36 @@ public class UserService : UserContract.UserContractBase }; } - // Simulate file upload and generate URL - var fileName = $"avatar_{DateTime.Now.Ticks}.{request.FileMimeType?.Split('/').LastOrDefault()}"; - var avatarUrl = $"/uploads/avatars/{fileName}"; - + var userId = GetCurrentUserId(); + + var user = await _context.Users + .Where(u => u.Id == userId && !u.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (user == null) + throw new RpcException(new Status(StatusCode.NotFound, "کاربر یافت نشد")); + + // Upload to FMS + var fileBytes = request.FileData.ToByteArray(); + var fileName = $"avatar_{userId}_{DateTime.UtcNow.Ticks}"; + var mime = request.FileMimeType ?? "image/jpeg"; + + var avatarUrl = await _fileManagementService.UploadFileAsync( + "Avatars", fileBytes, mime, fileName, context.CancellationToken); + + if (string.IsNullOrEmpty(avatarUrl)) + { + return new UploadCustomerAvatarResponse + { + Success = false, + Message = "خطا در آپلود فایل. لطفاً مجدد تلاش کنید" + }; + } + + // Update user avatar path in DB + user.AvatarPath = avatarUrl; + await _context.SaveChangesAsync(context.CancellationToken); + return new UploadCustomerAvatarResponse { Success = true, @@ -295,8 +383,32 @@ public class UserService : UserContract.UserContractBase public override async Task UpdateCustomerSettings(UpdateCustomerSettingsRequest request, ServerCallContext context) { - // Mock implementation for Update Customer Settings - await Task.Delay(10); + var userId = GetCurrentUserId(); + + var user = await _context.Users + .Where(u => u.Id == userId && !u.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (user == null) + throw new RpcException(new Status(StatusCode.NotFound, "کاربر یافت نشد")); + + user.EmailNotifications = request.EmailNotifications; + user.SmsNotifications = request.SmsNotifications; + user.PushNotifications = request.PushNotifications; + // MarketingNotifications, PreferredLanguage, TimeZone, TwoFactorAuth + // are stored at application level if needed in future + + await _context.SaveChangesAsync(context.CancellationToken); return new Empty(); } + + // ============= Helper Methods ============= + + private long GetCurrentUserId() + { + if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0) + return userId; + + throw new RpcException(new Status(StatusCode.Unauthenticated, "لطفاً وارد حساب کاربری خود شوید")); + } } diff --git a/src/CMSMicroservice.WebApi/Services/UserWalletService.cs b/src/CMSMicroservice.WebApi/Services/UserWalletService.cs index 58e1e79..cafa59a 100644 --- a/src/CMSMicroservice.WebApi/Services/UserWalletService.cs +++ b/src/CMSMicroservice.WebApi/Services/UserWalletService.cs @@ -8,16 +8,29 @@ using CMSMicroservice.Application.UserWalletCQ.Queries.GetAllUserWalletByFilter; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWalletChangeLog; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings; +using CMSMicroservice.Application.Common.Interfaces; +using Grpc.Core; +using Microsoft.EntityFrameworkCore; +using System.Linq; + namespace CMSMicroservice.WebApi.Services; public class UserWalletService : UserWalletContract.UserWalletContractBase { private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly ISender _sender; + private readonly IApplicationDbContext _context; + private readonly ICurrentUserService _currentUserService; - public UserWalletService(IDispatchRequestToCQRS dispatchRequestToCQRS, ISender sender) + public UserWalletService( + IDispatchRequestToCQRS dispatchRequestToCQRS, + ISender sender, + IApplicationDbContext context, + ICurrentUserService currentUserService) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; + _context = context; + _currentUserService = currentUserService; } public override async Task CreateNewUserWallet(CreateNewUserWalletRequest request, ServerCallContext context) { @@ -100,10 +113,38 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase public override async Task CustomerWithdrawBalance(CustomerWithdrawBalanceRequest request, ServerCallContext context) { - // Mock implementation - would handle withdrawal + var userId = GetCurrentUserId(); + + // Find the commission payout record + var payout = await _context.UserCommissionPayouts + .Where(p => p.Id == request.PayoutId && p.UserId == userId && !p.IsDeleted) + .FirstOrDefaultAsync(context.CancellationToken); + + if (payout == null) + throw new RpcException(new Status(StatusCode.NotFound, "رکورد پرداخت کمیسیون یافت نشد")); + + // Validate status - can only withdraw if already paid to wallet + if (payout.Status != Domain.Enums.CommissionPayoutStatus.Paid) + throw new RpcException(new Status(StatusCode.FailedPrecondition, + "فقط کمیسیون‌های واریز شده به کیف پول قابل برداشت هستند")); + + // Update payout with withdrawal info + payout.WithdrawalMethod = (Domain.Enums.WithdrawalMethod)request.WithdrawalMethod; + payout.IbanNumber = request.IbanNumber; + payout.Status = Domain.Enums.CommissionPayoutStatus.WithdrawRequested; + + await _context.SaveChangesAsync(context.CancellationToken); + return new Google.Protobuf.WellKnownTypes.Empty(); } + private long GetCurrentUserId() + { + if (long.TryParse(_currentUserService.UserId, out var userId) && userId > 0) + return userId; + throw new RpcException(new Status(StatusCode.Unauthenticated, "لطفاً وارد حساب کاربری خود شوید")); + } + public override async Task GetCustomerWithdrawals(GetCustomerWithdrawalsRequest request, ServerCallContext context) { var query = new GetCustomerWithdrawalsQuery diff --git a/src/CMSMicroservice.WebApi/appsettings.json b/src/CMSMicroservice.WebApi/appsettings.json index 9b40a55..c3138d0 100644 --- a/src/CMSMicroservice.WebApi/appsettings.json +++ b/src/CMSMicroservice.WebApi/appsettings.json @@ -1,5 +1,8 @@ { "UseRealPaymentGateway": false, + "FMS": { + "Address": "https://dl.afrino.co" + }, "JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=", "JwtIssuer": "https://localhost", "JwtAudience": "https://localhost",