feat: Implement file management and authorization features
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m9s

- Add RequiresPermissionAttribute for gRPC method access control.
- Create IFileManagementService interface for file upload and management.
- Implement AddProductImageCommand and handler for adding product images.
- Implement CreateNewProductsCommand and handler for creating new products with image uploads.
- Implement DeleteProductsCommand and handler for deleting products and their associations.
- Implement RemoveProductImageCommand and handler for removing product images from galleries.
- Implement UpdateProductsCommand and handler for updating product details and images.
- Create GetProductGalleryQuery and handler for retrieving product galleries.
- Implement PermissionService for role-based access control using JWT claims.
- Implement FileManagementService for handling file uploads and image optimization.
- Define gRPC service and messages for file management in fms.proto.
- Add FluentValidation for request validation in various commands.
- Create PermissionInterceptor for enforcing permissions on gRPC methods.
This commit is contained in:
masoodafar-web
2026-02-10 22:04:54 +03:30
parent f64b6be7da
commit b42d9e141d
65 changed files with 3082 additions and 2384 deletions
-619
View File
@@ -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 کیف پول با تراکنش‌های کامل)
-591
View File
@@ -1,591 +0,0 @@
# پیاده‌سازی ICurrentUserService در سرویس‌های Customer
## خلاصه تغییرات
این سند تمام تغییرات انجام شده برای پیاده‌سازی احراز هویت مبتنی بر JWT در endpoint‌های Customer را مستند می‌کند. هدف اصلی حذف نیاز به ارسال صریح UserId از سمت کلاینت و استخراج خودکار آن از JWT Claims است.
## الگوی پیاده‌سازی
### الگوی Query Handler (با ICurrentUserService)
```csharp
public class SomeQueryHandler : IRequestHandler<SomeQuery, SomeResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public SomeQueryHandler(IApplicationDbContext context, ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<SomeResponseDto> 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<Response> 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<TCommand, TResponse>` که 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<long> { 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<long>
---
### ✅ 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 هستند.
-179
View File
@@ -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<Response> CustomerMethod(Request request, ServerCallContext context)
{
// Implementation
}
#endregion
// Admin Methods Section
#region Admin Methods
public override async Task<Response> 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
-190
View File
@@ -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 همگام شد (۳ ژانویه ۲۰۲۶)**
+1
View File
@@ -0,0 +1 @@
Docs moved to /totalDoc — see totalDoc/INDEX.md
-490
View File
@@ -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<List<UserClubFeatureDto>>
{
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<ToggleUserClubFeatureResponse>
{
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<GetUserClubFeaturesResponse> GetUserClubFeatures(
GetUserClubFeaturesRequest request,
ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<
GetUserClubFeaturesRequest,
GetUserClubFeaturesQuery,
GetUserClubFeaturesResponse>(request, context);
}
public override async Task<Protobuf.Protos.ClubMembership.ToggleUserClubFeatureResponse>
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<UserClubFeatureDto>``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
@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.Common.Authorization;
/// <summary>
/// سرویس بررسی مجوز کاربر بر اساس نقش‌های JWT
/// </summary>
public interface IPermissionService
{
/// <summary>
/// دریافت نقش‌های کاربر فعلی از JWT Claims
/// </summary>
Task<IReadOnlyList<string>> GetUserRolesAsync(CancellationToken cancellationToken);
/// <summary>
/// بررسی اینکه آیا کاربر فعلی مجوز مشخصی دارد
/// </summary>
Task<bool> HasPermissionAsync(string permission, CancellationToken cancellationToken);
}
@@ -0,0 +1,127 @@
namespace CMSMicroservice.Application.Common.Authorization;
/// <summary>
/// ثوابت نام مجوزها — دسته‌بندی شده بر اساس حوزه
/// </summary>
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";
}
/// <summary>
/// نام نقش‌ها
/// </summary>
public static class RoleNames
{
public const string SuperAdmin = "Administrator";
public const string Admin = "Admin";
public const string Inspector = "Inspector";
}
/// <summary>
/// تنظیمات نقش→مجوز — ماتریس دسترسی
/// </summary>
public static class RolePermissionConfig
{
private static readonly Dictionary<string, HashSet<string>> 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
}
};
/// <summary>
/// بررسی اینکه آیا نقش مشخصی مجوز خاصی دارد
/// </summary>
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);
}
}
@@ -0,0 +1,15 @@
namespace CMSMicroservice.Application.Common.Authorization;
/// <summary>
/// Attribute برای مشخص کردن مجوز لازم برای دسترسی به یک متد gRPC
/// </summary>
[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; }
}
@@ -0,0 +1,37 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Service for uploading files to FMS (File Management Service)
/// </summary>
public interface IFileManagementService
{
/// <summary>
/// Uploads a file to FMS and returns the stored file path
/// </summary>
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
/// <param name="fileBytes">Raw file bytes</param>
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
/// <param name="fileName">Original file name</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The stored file path returned by FMS, or null if upload failed</returns>
Task<string?> UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName, CancellationToken cancellationToken = default);
/// <summary>
/// Uploads an image to FMS with optimization (resize + compress)
/// Returns both main image path and thumbnail path
/// </summary>
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
/// <param name="fileBytes">Raw image bytes</param>
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
/// <param name="fileName">Original file name</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Tuple of (mainImagePath, thumbnailPath), either can be null if upload failed</returns>
Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync(
string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a file from FMS by its ID
/// </summary>
Task<bool> DeleteFileAsync(long fileId, CancellationToken cancellationToken = default);
}
@@ -57,7 +57,7 @@ public class CreateNewOtpTokenCommandHandler : IRequestHandler<CreateNewOtpToken
};
await _context.OtpTokens.AddAsync(entity, cancellationToken);
entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity));
entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity, code));
await _context.SaveChangesAsync(cancellationToken);
return new CreateNewOtpTokenResponseDto()
{
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Events;
using Microsoft.Extensions.Logging;
@@ -6,16 +7,29 @@ namespace CMSMicroservice.Application.OtpTokenCQ.EventHandlers;
public class CreateNewOtpTokenEventHandler : INotificationHandler<CreateNewOtpTokenEvent>
{
private readonly ILogger<CreateNewOtpTokenEventHandler> _logger;
private readonly IKavenegarService _kavenegarService;
public CreateNewOtpTokenEventHandler(ILogger<CreateNewOtpTokenEventHandler> logger)
public CreateNewOtpTokenEventHandler(
ILogger<CreateNewOtpTokenEventHandler> 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);
}
}
}
@@ -0,0 +1,10 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage;
public record AddProductImageCommand : IRequest<AddProductImageResponseDto>
{
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; }
}
@@ -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<AddProductImageCommand, AddProductImageResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly ILogger<AddProductImageCommandHandler> _logger;
public AddProductImageCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
ILogger<AddProductImageCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_logger = logger;
}
public async Task<AddProductImageResponseDto> 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
};
}
}
@@ -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;
}
@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
public record CreateNewProductsCommand : IRequest<CreateNewProductsResponseDto>
{
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<long> 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; }
}
@@ -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<CreateNewProductsCommand, CreateNewProductsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly ILogger<CreateNewProductsCommandHandler> _logger;
public CreateNewProductsCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
ILogger<CreateNewProductsCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_logger = logger;
}
public async Task<CreateNewProductsResponseDto> 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 };
}
}
@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
public class CreateNewProductsCommandValidator : AbstractValidator<CreateNewProductsCommand>
{
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<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<CreateNewProductsCommand>.CreateWithOptions(
(CreateNewProductsCommand)model, x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
public class CreateNewProductsResponseDto
{
public long Id { get; set; }
}
@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.DeleteProducts;
public record DeleteProductsCommand : IRequest<Unit>
{
public long Id { get; init; }
}
@@ -0,0 +1,38 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
namespace CMSMicroservice.Application.ProductsCQ.Commands.DeleteProducts;
public class DeleteProductsCommandHandler : IRequestHandler<DeleteProductsCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeleteProductsCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> 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;
}
}
@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.RemoveProductImage;
public record RemoveProductImageCommand : IRequest<Unit>
{
public long ProductGalleryId { get; init; }
}
@@ -0,0 +1,40 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
namespace CMSMicroservice.Application.ProductsCQ.Commands.RemoveProductImage;
public class RemoveProductImageCommandHandler : IRequestHandler<RemoveProductImageCommand, Unit>
{
private readonly IApplicationDbContext _context;
public RemoveProductImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> 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;
}
}
@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts;
public record UpdateProductsCommand : IRequest<Unit>
{
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<long> 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; }
}
@@ -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<UpdateProductsCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly ILogger<UpdateProductsCommandHandler> _logger;
public UpdateProductsCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
ILogger<UpdateProductsCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_logger = logger;
}
public async Task<Unit> 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;
}
}
@@ -0,0 +1,29 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts;
public class UpdateProductsCommandValidator : AbstractValidator<UpdateProductsCommand>
{
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<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<UpdateProductsCommand>.CreateWithOptions(
(UpdateProductsCommand)model, x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -24,7 +24,7 @@ public class GetCustomerProductsQueryHandler : IRequestHandler<GetCustomerProduc
.FirstOrDefaultAsync(cancellationToken);
if (product == null)
throw new NotFoundException(nameof(Products), request.Id);
throw new NotFoundException(nameof(Product), request.Id);
var response = new GetCustomerProductsResponseDto
{
@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery;
public class GetProductGalleryQuery : IRequest<GetProductGalleryResponseDto>
{
public long ProductId { get; set; }
}
@@ -0,0 +1,34 @@
using CMSMicroservice.Application.Common.Interfaces;
namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery;
public class GetProductGalleryQueryHandler : IRequestHandler<GetProductGalleryQuery, GetProductGalleryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetProductGalleryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetProductGalleryResponseDto> 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()
};
}
}
@@ -0,0 +1,15 @@
namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery;
public class GetProductGalleryResponseDto
{
public List<ProductGalleryItemDto> 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;
}
@@ -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<AcceptContractComman
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUserService;
private readonly IHashService _hashService;
private readonly IGenerateJwtToken _generateJwt;
private readonly IConfiguration _cfg;
public AcceptContractCommandHandler(IApplicationDbContext context, ICurrentUserService currentUserService)
public AcceptContractCommandHandler(IApplicationDbContext context, ICurrentUserService currentUserService,
IHashService hashService, IGenerateJwtToken generateJwt, IConfiguration cfg)
{
_context = context;
_currentUserService = currentUserService;
_hashService = hashService;
_generateJwt = generateJwt;
_cfg = cfg;
}
public async Task<AcceptContractResponseDto> 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<AcceptContractComman
await _context.SaveChangesAsync(cancellationToken);
// TODO: Implement JWT token generation
// Generate JWT token with updated contract status
var token = await _generateJwt.GenerateJwtToken(user);
return new AcceptContractResponseDto
{
IsSuccess = true,
Message = "قرارداد با موفقیت تایید شد",
Token = "TODO_IMPLEMENT_JWT_GENERATION"
Token = token
};
}
}
@@ -1,26 +1,41 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace CMSMicroservice.Application.UserCQ.Commands.VerifyOtpToken;
public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenCommand, VerifyOtpTokenResponseDto>
{
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<VerifyOtpTokenResponseDto> 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
@@ -18,14 +18,11 @@ public class OtpToken : BaseAuditableEntity
public bool IsUsed { get; set; }
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -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; }
}
@@ -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> ProductGalleries { get; set; }
}
@@ -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 =============
/// <summary>
/// آیا این محصول فقط در فروشگاه باشگاه موجود است
/// </summary>
public bool IsClubExclusive { get; set; }
/// <summary>
/// درصد تخفیف باشگاه (0 تا 100)
/// </summary>
public int ClubDiscountPercent { get; set; }
// ============= Navigation Properties =============
//UserCarts Collection Navigation Reference
public virtual ICollection<UserCart> UserCarts { get; set; }
//ProductGalleries Collection Navigation Reference
public virtual ICollection<ProductGallery> ProductGalleries { get; set; }
//FactorDetails Collection Navigation Reference
public virtual ICollection<FactorDetails> FactorDetails { get; set; }
//ProductCategory Collection Navigation Reference
public virtual ICollection<ProductCategory> ProductCategories { get; set; }
//ProductTag Collection Navigation Reference
public virtual ICollection<ProductTag> ProductTags { get; set; }
}
@@ -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; }
/// <summary>
/// کد OTP به صورت plain text برای ارسال SMS
/// </summary>
public string PlainCode { get; }
}
@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.54.0" />
<PackageReference Include="Kavenegar" Version="1.2.5" />
<PackageReference Include="MailKit" Version="4.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.11" />
@@ -18,10 +19,12 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" />
<PackageReference Include="Polly" Version="8.5.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CMSMicroservice.Application\CMSMicroservice.Application.csproj" />
<ProjectReference Include="..\CMSMicroservice.Protobuf\CMSMicroservice.Protobuf.csproj" />
</ItemGroup>
<ItemGroup>
@@ -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<IAlertService, AlertService>();
services.AddScoped<IUserNotificationService, UserNotificationService>();
services.AddScoped<IKavenegarService, KavenegarService>();
services.AddScoped<IFileManagementService, FileManagementService>();
services.AddScoped<IPermissionService, PermissionService>();
// Daya Loan API Service - قابل تغییر بین Mock و Real
var useMockDayaApi = configuration.GetValue<bool>("DayaApi:UseMock", false);
@@ -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;
/// <summary>
/// پیاده‌سازی سرویس مجوز — نقش‌ها از JWT Claims خوانده میشن
/// </summary>
public class PermissionService : IPermissionService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public PermissionService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Task<IReadOnlyList<string>> GetUserRolesAsync(CancellationToken cancellationToken)
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity is not { IsAuthenticated: true })
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
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<IReadOnlyList<string>>(roles);
}
public async Task<bool> 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;
}
}
@@ -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<FileManagementService> _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<FileManagementService> 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<string?> 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<bool> 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<byte[]> 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();
}
}
@@ -64,6 +64,8 @@
<Protobuf Include="Protos\appversion.proto" ProtoRoot="Protos\" GrpcServices="Both" />
<!-- Inventory Management System -->
<Protobuf Include="Protos\inventory.proto" ProtoRoot="Protos\" GrpcServices="Both" />
<!-- FMS (File Management Service) - gRPC Client only -->
<Protobuf Include="Protos\fms.proto" ProtoRoot="Protos\" GrpcServices="Client" />
</ItemGroup>
<Target Name="PushToFoursatNuget" AfterTargets="Pack" Condition="'$(CI)' != 'true'">
@@ -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;
}
@@ -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;
}
@@ -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 {
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,23 @@
using FluentValidation;
using CMSMicroservice.Protobuf.Protos.User;
namespace CMSMicroservice.Protobuf.Validator.User;
public class VerifyOtpTokenRequestValidator : AbstractValidator<VerifyOtpTokenRequest>
{
public VerifyOtpTokenRequestValidator()
{
RuleFor(model => model.Mobile)
.NotEmpty();
RuleFor(model => model.Purpose)
.NotEmpty();
RuleFor(model => model.Code)
.NotEmpty();
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(ValidationContext<VerifyOtpTokenRequest>.CreateWithOptions((VerifyOtpTokenRequest)model, x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -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;
/// <summary>
/// gRPC Interceptor برای بررسی مجوز دسترسی
/// بر اساس [RequiresPermission] attribute روی سرویس‌ها/متدها
/// </summary>
public class PermissionInterceptor : Interceptor
{
private readonly IPermissionService _permissionService;
private readonly ILogger<PermissionInterceptor> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
public PermissionInterceptor(
IPermissionService permissionService,
ILogger<PermissionInterceptor> logger,
IHttpContextAccessor httpContextAccessor)
{
_permissionService = permissionService;
_logger = logger;
_httpContextAccessor = httpContextAccessor;
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
await EnsureHasPermissionAsync(context);
return await continuation(request, context);
}
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream, ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> 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<RequiresPermissionAttribute>();
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})"));
}
}
}
}
+2
View File
@@ -64,6 +64,7 @@ builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<LoggingBehaviour>();
options.Interceptors.Add<PerformanceBehaviour>();
options.Interceptors.Add<CMSMicroservice.WebApi.Interceptors.PermissionInterceptor>();
//options.Interceptors.Add<ExceptionHandlingBehaviour>();
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<TokenNotificationHub>("/hubs/token-notification");
app.MapHub<TokenNotificationHub>("/hubs/token-relay"); // Alias for FrontOffice backward compatibility
app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints =>
{
@@ -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<GetAppVersionResponse> GetAppVersion(GetAppVersionRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetAppVersionRequest, GetAppVersionQuery, GetAppVersionResponse>(request, context);
}
[RequiresPermission(PermissionNames.SettingsManageConfiguration)]
public override async Task<Empty> UpdateAppVersion(UpdateAppVersionRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<UpdateAppVersionRequest, UpdateAppVersionCommand>(request, context);
}
[RequiresPermission(PermissionNames.SettingsView)]
public override async Task<GetAllAppVersionsResponse> GetAllAppVersions(GetAllAppVersionsRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetAllAppVersionsRequest, GetAllAppVersionsQuery, GetAllAppVersionsResponse>(request, context);
@@ -100,7 +100,22 @@ public class CategoryService : CategoryContract.CategoryContractBase
public override async Task<GetCategoryByIdForCustomerResponse> 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
}
};
}
}
@@ -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<GetAllCitiesByFilterResponse> GetAllCitiesByFilter(
@@ -28,18 +34,50 @@ public class CityService : CityContract.CityContractBase
public override async Task<GetCitiesForCustomerResponse> 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<GetCityByIdForCustomerResponse> 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<GetCitiesByStateForCustomerResponse> 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<CreateCityResponse> 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<Empty> 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<Empty> 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
@@ -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
/// <summary>
/// دریافت تمام تنظیمات
/// </summary>
[RequiresPermission(PermissionNames.SettingsView)]
public override Task<GetAllConfigurationsResponse> GetAllConfigurations(GetAllConfigurationsRequest request, ServerCallContext context)
{
var response = new GetAllConfigurationsResponse();
@@ -145,16 +147,18 @@ public class ConfigurationService : ConfigurationContract.ConfigurationContractB
/// <summary>
/// سایر عملیات‌ها که فعلاً پیاده‌سازی نشده‌اند (چون از constant استفاده می‌کنیم)
/// </summary>
[RequiresPermission(PermissionNames.SettingsManageConfiguration)]
public override Task<Empty> 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<Empty> 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<GetConfigurationHistoryResponse> GetConfigurationHistory(GetConfigurationHistoryRequest request, ServerCallContext context)
@@ -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<ReserveStockResponse> ReserveStock(ReserveStockRequest request, ServerCallContext context)
public override async Task<ReserveStockResponse> 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<Empty> ReleaseReservation(ReleaseReservationRequest request, ServerCallContext context)
public override async Task<Empty> 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<Empty> ConfirmSale(ConfirmSaleRequest request, ServerCallContext context)
public override async Task<Empty> 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<ProcessReturnResponse> ProcessReturn(ProcessReturnRequest request, ServerCallContext context)
public override async Task<ProcessReturnResponse> 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<Empty> RecordLoss(RecordLossRequest request, ServerCallContext context)
@@ -255,10 +341,48 @@ public class InventoryService : InventoryContract.InventoryContractBase
// ========== Bulk Operations ==========
public override Task<BulkAddStockResponse> BulkAddStock(BulkAddStockRequest request, ServerCallContext context)
public override async Task<BulkAddStockResponse> 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<GetInventorySummaryResponse> GetInventorySummary(GetInventorySummaryRequest request, ServerCallContext context)
public override async Task<GetInventorySummaryResponse> 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<GetStockValueReportResponse> GetStockValueReport(GetStockValueReportRequest request, ServerCallContext context)
public override async Task<GetStockValueReportResponse> 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
};
}
}
@@ -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<CreateManualPaymentResponse> CreateManualPayment(
CreateManualPaymentRequest request,
ServerCallContext context)
@@ -37,6 +39,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
};
}
[RequiresPermission(PermissionNames.ManualPaymentsApprove)]
public override async Task<Google.Protobuf.WellKnownTypes.Empty> ApproveManualPayment(
ApproveManualPaymentRequest request,
ServerCallContext context)
@@ -44,6 +47,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
return await _dispatchRequestToCQRS.Handle<ApproveManualPaymentRequest, ApproveManualPaymentCommand>(request, context);
}
[RequiresPermission(PermissionNames.ManualPaymentsApprove)]
public override async Task<Google.Protobuf.WellKnownTypes.Empty> RejectManualPayment(
RejectManualPaymentRequest request,
ServerCallContext context)
@@ -51,6 +55,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
return await _dispatchRequestToCQRS.Handle<RejectManualPaymentRequest, RejectManualPaymentCommand>(request, context);
}
[RequiresPermission(PermissionNames.ManualPaymentsView)]
public override async Task<GetAllManualPaymentsResponse> GetAllManualPayments(
GetAllManualPaymentsRequest request,
ServerCallContext context)
@@ -58,6 +63,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
return await _dispatchRequestToCQRS.Handle<GetAllManualPaymentsRequest, GetAllManualPaymentsQuery, GetAllManualPaymentsResponse>(request, context);
}
[RequiresPermission(PermissionNames.ManualPaymentsCreate)]
public override async Task<ProcessManualMembershipPaymentResponse> ProcessManualMembershipPayment(
ProcessManualMembershipPaymentRequest request,
ServerCallContext context)
@@ -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<CreateNewPackageResponse> CreateNewPackage(CreateNewPackageRequest request, ServerCallContext context)
{
@@ -142,45 +156,161 @@ public class PackageService : PackageContract.PackageContractBase
public override async Task<CustomerPurchasePackageResponse> 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<CustomerVerifyPackagePurchaseResponse> 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 = isSuccessful,
Message = isSuccessful ? "خرید پکیج با موفقیت تایید شد" : "خرید پکیج ناموفق بود",
TransactionId = transactionId,
ReferenceCode = referenceCode,
PurchaseInfo = isSuccessful ? new PackagePurchaseInfo
Success = false,
Message = "پرداخت توسط کاربر لغو شد"
};
}
// Verify with payment gateway
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority, request.Status, context.CancellationToken);
if (verifyResult.IsSuccess && transaction != null)
{
PackageId = 1,
PackageName = "پکیج طلایی",
AmountPaid = 5600000,
PurchaseDate = Timestamp.FromDateTime(DateTime.UtcNow),
ExpiryDate = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(365))
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 = verifyResult.IsSuccess,
Message = verifyResult.IsSuccess ? "خرید پکیج با موفقیت تایید شد" : (verifyResult.Message ?? "خرید پکیج ناموفق بود"),
TransactionId = transaction?.Id ?? 0,
ReferenceCode = verifyResult.RefId ?? string.Empty,
PurchaseInfo = verifyResult.IsSuccess ? new PackagePurchaseInfo
{
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<GetCustomerPurchaseHistoryResponse> GetCustomerPurchaseHistory(GetCustomerPurchaseHistoryRequest request, ServerCallContext context)
{
var query = new GetCustomerPurchaseHistoryQuery
@@ -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<CreateNewProductsResponse> 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<long>(),
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<Google.Protobuf.WellKnownTypes.Empty> 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<long>(),
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<Google.Protobuf.WellKnownTypes.Empty> 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<GetProductsResponse> 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<GetAllProductsByFilterResponse> GetAllProductsByFilter(GetAllProductsByFilterRequest request, ServerCallContext context)
@@ -102,22 +223,256 @@ public class ProductsService : ProductsContract.ProductsContractBase
public override async Task<BulkUpdateProductPricesResponse> 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<BulkUpdateProductStockResponse> 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<GetLowStockProductsResponse> 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<ToggleProductStatusResponse> 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<GetProductsForCategoryResponse> 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<GetCategoriesResponse> 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<Google.Protobuf.WellKnownTypes.Empty> 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<Google.Protobuf.WellKnownTypes.Empty> 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<AddProductImageResponse> 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<Google.Protobuf.WellKnownTypes.Empty> 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<GetProductGalleryResponse> 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;
}
}
@@ -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<CreateNewTransactionsResponse> CreateNewTransactions(CreateNewTransactionsRequest request, ServerCallContext context)
{
@@ -121,26 +136,114 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase
public override async Task<CustomerPaymentRequestResponse> 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<CustomerPaymentVerificationResponse> 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 = 12345,
PaymentStatus = isSuccessful,
Message = isSuccessful ? "پرداخت با موفقیت انجام شد" : "پرداخت ناموفق",
RefId = isSuccessful ? "REF123456789" : null,
OrderId = "ORDER001",
VerificationStatusCode = isSuccessful ? 101 : 102
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 = 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, "لطفاً وارد حساب کاربری خود شوید"));
}
}
@@ -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<AddNewUserCartResponse> 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<Empty> 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<Empty> 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<GetUserCartResponse> 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<GetAllUserCartsByFilterResponse> 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
@@ -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<CreateNewUserOrderResponse> 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<Google.Protobuf.WellKnownTypes.Empty> 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<Google.Protobuf.WellKnownTypes.Empty> 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<GetUserOrderResponse> 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<GetAllUserOrderByFilterResponse> 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<CancelOrderResponse> 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<UpdateOrderStatusResponse> 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<GetOrdersByDateRangeResponse> 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<ApplyDiscountToOrderResponse> 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<CalculateOrderPVResponse> 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<ProductPVDto>();
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<CustomerCancelOrderResponse> 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<CustomerTrackOrderResponse> CustomerTrackOrder(CustomerTrackOrderRequest request, ServerCallContext context)
{
// Mock Customer order tracking with detailed Persian information
var statusHistory = new List<OrderStatusHistory>
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<DeliveryStep>
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<CustomerReorderResponse> 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()
};
}
@@ -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<CreateNewUserResponse> CreateNewUser(CreateNewUserRequest request, ServerCallContext context)
{
@@ -90,33 +107,58 @@ public class UserService : UserContract.UserContractBase
public override async Task<GetUserForCustomerResponse> 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<Empty> 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<ChangeCustomerPasswordResponse> 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<UploadCustomerAvatarResponse> 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,9 +326,35 @@ 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
{
@@ -295,8 +383,32 @@ public class UserService : UserContract.UserContractBase
public override async Task<Empty> 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, "لطفاً وارد حساب کاربری خود شوید"));
}
}
@@ -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<CreateNewUserWalletResponse> CreateNewUserWallet(CreateNewUserWalletRequest request, ServerCallContext context)
{
@@ -100,10 +113,38 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
public override async Task<Google.Protobuf.WellKnownTypes.Empty> 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<GetCustomerWithdrawalsResponse> GetCustomerWithdrawals(GetCustomerWithdrawalsRequest request, ServerCallContext context)
{
var query = new GetCustomerWithdrawalsQuery
@@ -1,5 +1,8 @@
{
"UseRealPaymentGateway": false,
"FMS": {
"Address": "https://dl.afrino.co"
},
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",