3 Commits

Author SHA1 Message Date
masoodafar-web b42d9e141d 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.
2026-02-10 22:04:54 +03:30
masoodafar-web f64b6be7da feat: Implement GetMyWeeklyBalances query and handler, enhance ClubMembership service with JWT userId handling 2026-02-08 00:24:59 +03:30
masoodafar-web 9185aa227d feat: Add discount balance and change value to wallet change log
- Added CurrentDiscountBalance and ChangeDiscountValue properties to GetCustomerWalletChangeLogResponseDto.
- Updated userwallet.proto to include current_discount_balance and change_discount_value fields.
- Enhanced CommissionProfile mapping to support GetMyCommissionPayouts requests and responses.
- Implemented GetMyCommissionPayouts query and handler to retrieve user commission payouts.
- Added validation for GetMyCommissionPayouts query.
- Updated UserOrderService to handle user orders, including VAT calculations and wallet transactions.
- Enhanced UserWalletService to include new properties in wallet change log responses.
2026-02-07 22:03:49 +03:30
78 changed files with 3975 additions and 2110 deletions
-318
View File
@@ -1,318 +0,0 @@
# FrontOffice to CMS API Compatibility Analysis
**تاریخ:** 5 فوریه 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 قبل** |
**نتیجه:** ✅ تمام Products APIs موجود است
---
## 3. Category APIs
### استفاده شده در FrontOffice
| API Method | استفاده در Service | Status در CMS | یادداشت |
|------------|-------------------|---------------|---------|
| `GetAllCategories()` | CategoryService | ✅ موجود | Admin API: `GetAllCategoryByFilter()` |
| `GetCategoryById()` | CategoryService | ✅ موجود | Admin API: `GetCategory()` |
**یادداشت:** CategoryService در FrontOffice از Admin APIs استفاده می‌کند (بدون احراز هویت). این مشکلی ندارد چون Categories عمومی هستند.
**نتیجه:** ✅ Category APIs موجود است
---
## 4. UserOrder APIs
### استفاده شده در FrontOffice
| API Method | استفاده در Service | Status در CMS | یادداشت |
|------------|-------------------|---------------|---------|
| `GetAllUserOrderByFilter()` | OrderService | ✅ موجود | Proto: `GetCustomerOrders()` **پیاده شد** |
| `GetUserOrder()` | OrderService | ✅ موجود | Proto: `GetCustomerOrder()` **پیاده شد** |
| `GetUserOrderHistory()` | OrderService | ⚠️ نیاز به بررسی | Proto: `GetCustomerOrderHistory()` **پیاده شد** |
| `PlaceOrder()` (Checkout) | Checkout.razor | ❓ نیاز به بررسی | باید از Transaction یا UserOrder باشد |
**نتیجه:** ⚠️ نیاز به بررسی PlaceOrder workflow
---
## 5. UserWallet APIs
### استفاده شده در FrontOffice
| API Method | استفاده در Service | Status در CMS | یادداشت |
|------------|-------------------|---------------|---------|
| `GetCustomerWallet()` | WalletService | ✅ موجود | **پیاده شد در Task قبل** |
| `GetCustomerWalletChangeLog()` | WalletService | ✅ موجود | **پیاده شد در Task قبل** |
| `CustomerWithdrawBalance()` | WalletService | ✅ موجود | **پیاده شد در Task قبل** |
| `GetCustomerWithdrawals()` | WithdrawalRequests.razor | ✅ موجود | **پیاده شد در Task قبل** |
| `GetCustomerWithdrawalSettings()` | WalletService | ✅ موجود | **پیاده شد در Task قبل** |
**نتیجه:** ✅ تمام UserWallet APIs موجود است
---
## 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** |
**نتیجه:** ✅ تمام UserCart Customer APIs پیاده شده
---
## 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 جداگانه استفاده کند.
**نتیجه:** ✅ تمام UserAddress Customer APIs پیاده شده
---
## 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 | ✅ موجود | **پیاده شد در Task قبل** |
| `GetSubordinateTree()` | NetworkMembershipService | ✅ موجود | **پیاده شد در Task قبل** |
| `GetMyNetworkStatistics()` | NetworkStatisticsPage.razor | ✅ موجود | **پیاده شد در Task قبل** |
**نتیجه:** ✅ تمام 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 - از Admin API استفاده می‌کند (OK)
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)** 🆕
14.**UserAddress APIs - تمام Customer endpoints پیاده شد (Feb 5, 2026)** 🆕
### ⚠️ نیاز به توجه
~~1. **UserCarts APIs** - نیاز به Customer-specific endpoints~~
**✅ تکمیل شد - Feb 5, 2026**
~~2. **UserAddress APIs** - نیاز به Customer-specific endpoints~~
**✅ تکمیل شد - Feb 5, 2026**
3. **UserOrder/Checkout APIs** - نیاز به بررسی:
- `PlaceOrder()` یا `CreateOrder()` - ❓ نیاز به بررسی workflow
- `CancelOrder()` - ❓ نیاز به بررسی
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
~~### 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 استفاده کند
### Priority 3: Checkout/Order Creation
باید workflow ثبت سفارش بررسی شود.
### Priority 4: Customer Profile Updates
پیاده‌سازی Handler های Update برای Customer.
---
## وضعیت پروژه
**تکمیل شده:** ~95%
**آخرین به‌روزرسانی:** 5 فوریه 2026
**تغییرات امروز:**
- ✅ پیاده‌سازی کامل UserCart Customer endpoints (4 Handler + Service)
- ✅ پیاده‌سازی کامل UserAddress Customer endpoints (5 Handler + Service)
- ✅ اضافه کردن Proto definitions برای Customer Address
- ✅ Build موفقیت‌آمیز: 0 Errors
**باقی مانده:**
- ⚠️ Checkout workflow و Order creation (نیاز به بررسی)
- ⚠️ Profile update handlers (UpdateCustomerProfile, ChangePassword, UpdateSettings)
- 📝 CityName/ProvinceName در GetCustomerAddresses خالی است (نیاز به City API lookup در FrontOffice)
**Build Status:**
- ✅ 0 Errors
- ⚠️ ~315 Warnings (nullable reference types - غیر بحرانی)
-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
@@ -3,14 +3,18 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership
public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQuery, ClubMembershipDto?> public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQuery, ClubMembershipDto?>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ILogger<GetClubMembershipQueryHandler> _logger;
public GetClubMembershipQueryHandler(IApplicationDbContext context) public GetClubMembershipQueryHandler(IApplicationDbContext context, ILogger<GetClubMembershipQueryHandler> logger)
{ {
_context = context; _context = context;
_logger = logger;
} }
public async Task<ClubMembershipDto?> Handle(GetClubMembershipQuery request, CancellationToken cancellationToken) public async Task<ClubMembershipDto?> Handle(GetClubMembershipQuery request, CancellationToken cancellationToken)
{ {
_logger.LogInformation("GetClubMembership called for UserId: {UserId}", request.UserId);
var membership = await _context.ClubMemberships var membership = await _context.ClubMemberships
.AsNoTracking() .AsNoTracking()
.Where(x => x.UserId == request.UserId) .Where(x => x.UserId == request.UserId)
@@ -27,6 +31,26 @@ public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQu
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
// اگر کاربر عضویت نداره، یک DTO با وضعیت غیرفعال برگردون
if (membership == null)
{
_logger.LogInformation("No membership found for UserId: {UserId}, returning inactive status", request.UserId);
return new ClubMembershipDto
{
Id = 0,
UserId = request.UserId,
IsActive = false,
ActivatedAt = null,
InitialContribution = 0,
TotalEarned = 0,
Created = DateTimeOffset.UtcNow,
LastModified = null
};
}
_logger.LogInformation("Membership found for UserId: {UserId}, IsActive: {IsActive}, Id: {Id}",
request.UserId, membership.IsActive, membership.Id);
return membership; return membership;
} }
} }
@@ -0,0 +1,25 @@
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
/// <summary>
/// Query برای دریافت پرداخت‌های کمیسیون کاربر جاری (از JWT)
/// </summary>
public record GetMyCommissionPayoutsQuery : IRequest<GetMyCommissionPayoutsResponseDto>
{
/// <summary>
/// فیلتر وضعیت
/// </summary>
public CommissionPayoutStatus? Status { get; init; }
/// <summary>
/// شماره هفته (اختیاری)
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// Pagination
/// </summary>
public PaginationState? PaginationState { get; init; }
}
@@ -0,0 +1,73 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Extensions;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissionPayoutsQuery, GetMyCommissionPayoutsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public GetMyCommissionPayoutsQueryHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<GetMyCommissionPayoutsResponseDto> Handle(GetMyCommissionPayoutsQuery request, CancellationToken cancellationToken)
{
// دریافت UserId از JWT (فقط برای Customer API)
if (!long.TryParse(_currentUser.UserId, out var userId))
{
throw new UnauthorizedAccessException("User not authenticated");
}
var query = _context.UserCommissionPayouts
.Include(x => x.WeekDefinition)
.Where(x => x.UserId == userId)
.AsNoTracking()
.AsQueryable();
// فیلترها
if (request.Status.HasValue)
{
query = query.Where(x => x.Status == request.Status.Value);
}
if (request.WeekDefinitionId.HasValue)
{
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
}
// مرتب‌سازی: جدیدترین اول
query = query.OrderByDescending(x => x.Created);
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetMyCommissionPayoutsResponseModel
{
Id = x.Id,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
BalancesEarned = x.BalancesEarned,
TotalAmount = x.TotalAmount,
AmountFormatted = x.TotalAmount.ToString("N0") + " تومان",
Status = x.Status,
CalculatedDate = x.PaidAt ?? (DateTime?)x.Created,
DatePersian = ""
})
.ToListAsync(cancellationToken);
return new GetMyCommissionPayoutsResponseDto
{
MetaData = meta,
Models = models
};
}
}
@@ -0,0 +1,25 @@
using FluentValidation;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
public class GetMyCommissionPayoutsQueryValidator : AbstractValidator<GetMyCommissionPayoutsQuery>
{
public GetMyCommissionPayoutsQueryValidator()
{
RuleFor(x => x.PaginationState)
.NotNull()
.WithMessage("Pagination state is required");
When(x => x.PaginationState != null, () =>
{
RuleFor(x => x.PaginationState!.PageNumber)
.GreaterThan(0)
.WithMessage("Page number must be greater than 0");
RuleFor(x => x.PaginationState!.PageSize)
.GreaterThan(0)
.LessThanOrEqualTo(100)
.WithMessage("Page size must be between 1 and 100");
});
}
}
@@ -0,0 +1,23 @@
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
public class GetMyCommissionPayoutsResponseDto
{
public MetaData? MetaData { get; set; }
public List<GetMyCommissionPayoutsResponseModel> Models { get; set; } = new();
}
public class GetMyCommissionPayoutsResponseModel
{
public long Id { get; set; }
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public int BalancesEarned { get; set; }
public long TotalAmount { get; set; }
public string AmountFormatted { get; set; } = string.Empty;
public CommissionPayoutStatus Status { get; set; }
public DateTime? CalculatedDate { get; set; }
public string DatePersian { get; set; } = string.Empty;
}
@@ -0,0 +1,24 @@
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyWeeklyBalances;
/// <summary>
/// Query برای دریافت تعادل‌های هفتگی کاربر جاری (از JWT)
/// </summary>
public record GetMyWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesResponseDto>
{
/// <summary>
/// شناسه تعریف هفته (اختیاری)
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فقط موارد Expired نشده؟
/// </summary>
public bool OnlyActive { get; init; }
/// <summary>
/// Pagination
/// </summary>
public PaginationState? PaginationState { get; init; }
}
@@ -0,0 +1,47 @@
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyWeeklyBalances;
/// <summary>
/// Handler برای دریافت تعادل‌های هفتگی کاربر جاری
/// </summary>
public class GetMyWeeklyBalancesQueryHandler : IRequestHandler<GetMyWeeklyBalancesQuery, GetUserWeeklyBalancesResponseDto>
{
private readonly ICurrentUserService _currentUserService;
private readonly IMediator _mediator;
private readonly ILogger<GetMyWeeklyBalancesQueryHandler> _logger;
public GetMyWeeklyBalancesQueryHandler(
ICurrentUserService currentUserService,
IMediator mediator,
ILogger<GetMyWeeklyBalancesQueryHandler> logger)
{
_currentUserService = currentUserService;
_mediator = mediator;
_logger = logger;
}
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetMyWeeklyBalancesQuery request, CancellationToken cancellationToken)
{
// دریافت UserId از JWT
if (!long.TryParse(_currentUserService.UserId, out var userId) || userId <= 0)
{
_logger.LogWarning("GetMyWeeklyBalances called without valid user authentication");
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
}
_logger.LogInformation("GetMyWeeklyBalances for UserId: {UserId}, WeekDefinitionId: {WeekDefinitionId}",
userId, request.WeekDefinitionId);
// فراخوانی GetUserWeeklyBalancesQuery با UserId از JWT
var query = new GetUserWeeklyBalancesQuery
{
UserId = userId,
WeekDefinitionId = request.WeekDefinitionId,
OnlyActive = request.OnlyActive,
PaginationState = request.PaginationState
};
return await _mediator.Send(query, cancellationToken);
}
}
@@ -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); await _context.OtpTokens.AddAsync(entity, cancellationToken);
entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity)); entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity, code));
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return new CreateNewOtpTokenResponseDto() return new CreateNewOtpTokenResponseDto()
{ {
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Events; using CMSMicroservice.Domain.Events;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -6,16 +7,29 @@ namespace CMSMicroservice.Application.OtpTokenCQ.EventHandlers;
public class CreateNewOtpTokenEventHandler : INotificationHandler<CreateNewOtpTokenEvent> public class CreateNewOtpTokenEventHandler : INotificationHandler<CreateNewOtpTokenEvent>
{ {
private readonly ILogger<CreateNewOtpTokenEventHandler> _logger; private readonly ILogger<CreateNewOtpTokenEventHandler> _logger;
private readonly IKavenegarService _kavenegarService;
public CreateNewOtpTokenEventHandler(ILogger<CreateNewOtpTokenEventHandler> logger) public CreateNewOtpTokenEventHandler(
ILogger<CreateNewOtpTokenEventHandler> logger,
IKavenegarService kavenegarService)
{ {
_logger = logger; _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); .FirstOrDefaultAsync(cancellationToken);
if (product == null) if (product == null)
throw new NotFoundException(nameof(Products), request.Id); throw new NotFoundException(nameof(Product), request.Id);
var response = new GetCustomerProductsResponseDto 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 CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using CMSMicroservice.Domain.Entities; using CMSMicroservice.Domain.Entities;
namespace CMSMicroservice.Application.UserCQ.Commands.AcceptContract; namespace CMSMicroservice.Application.UserCQ.Commands.AcceptContract;
@@ -7,25 +8,38 @@ public class AcceptContractCommandHandler : IRequestHandler<AcceptContractComman
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUserService; 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; _context = context;
_currentUserService = currentUserService; _currentUserService = currentUserService;
_hashService = hashService;
_generateJwt = generateJwt;
_cfg = cfg;
} }
public async Task<AcceptContractResponseDto> Handle(AcceptContractCommand request, CancellationToken cancellationToken) public async Task<AcceptContractResponseDto> Handle(AcceptContractCommand request, CancellationToken cancellationToken)
{ {
// Verify OTP first // Verify OTP first
var otpToken = await _context.OtpTokens var otpToken = await _context.OtpTokens
.Where(x => x.Mobile == _currentUserService.Username && x.Purpose == "signContract" && !x.IsUsed && x.Code == request.Code) .Where(x => x.Mobile == _currentUserService.Username && x.Purpose == "signContract" && !x.IsUsed)
.OrderByDescending(x => x.Id) // Use Id instead of CreatedAt for now .OrderByDescending(x => x.Id)
.FirstOrDefaultAsync(cancellationToken); .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 = "کد تایید نامعتبر است" }; return new AcceptContractResponseDto { IsSuccess = false, Message = "کد تایید نامعتبر است" };
var user = await _context.Users 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) .Where(x => x.Mobile == _currentUserService.Username)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
@@ -48,12 +62,14 @@ public class AcceptContractCommandHandler : IRequestHandler<AcceptContractComman
await _context.SaveChangesAsync(cancellationToken); 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 return new AcceptContractResponseDto
{ {
IsSuccess = true, IsSuccess = true,
Message = "قرارداد با موفقیت تایید شد", Message = "قرارداد با موفقیت تایید شد",
Token = "TODO_IMPLEMENT_JWT_GENERATION" Token = token
}; };
} }
} }
@@ -1,26 +1,41 @@
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace CMSMicroservice.Application.UserCQ.Commands.VerifyOtpToken; namespace CMSMicroservice.Application.UserCQ.Commands.VerifyOtpToken;
public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenCommand, VerifyOtpTokenResponseDto> public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenCommand, VerifyOtpTokenResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IGenerateJwtToken _generateJwt; 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; _context = context;
_generateJwt = generateJwt; _generateJwt = generateJwt;
_hashService = hashService;
_cfg = cfg;
} }
public async Task<VerifyOtpTokenResponseDto> Handle(VerifyOtpTokenCommand request, CancellationToken cancellationToken) public async Task<VerifyOtpTokenResponseDto> Handle(VerifyOtpTokenCommand request, CancellationToken cancellationToken)
{ {
var otpToken = await _context.OtpTokens var otpToken = await _context.OtpTokens
.Where(x => x.Mobile == request.Mobile && x.Purpose == request.Purpose && !x.IsUsed) .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); .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 = "کد تایید نامعتبر است" }; return new VerifyOtpTokenResponseDto { Success = false, Message = "کد تایید نامعتبر است" };
var user = await _context.Users var user = await _context.Users
@@ -22,6 +22,16 @@ public class GetCustomerWalletChangeLogResponseDto
/// </summary> /// </summary>
public long ChangeNerworkValue { get; set; } public long ChangeNerworkValue { get; set; }
/// <summary>
/// موجودی جاری تخفیف
/// </summary>
public long CurrentDiscountBalance { get; set; }
/// <summary>
/// مقدار تغییر تخفیف
/// </summary>
public long ChangeDiscountValue { get; set; }
/// <summary> /// <summary>
/// افزایشی است؟ /// افزایشی است؟
/// </summary> /// </summary>
@@ -18,14 +18,11 @@ public class OtpToken : BaseAuditableEntity
public bool IsUsed { get; set; } public bool IsUsed { get; set; }
/// <summary> /// <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> /// </summary>
public bool IsValid(string providedCode) public bool IsValid()
{ {
if (IsUsed || DateTime.UtcNow > ExpiresAt) return !IsUsed && DateTime.Now > ExpiresAt == false;
return false;
// Use BCrypt to verify the provided code against the stored hash
return BCrypt.Net.BCrypt.Verify(providedCode, CodeHash);
} }
} }
@@ -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; namespace CMSMicroservice.Domain.Events;
public class CreateNewOtpTokenEvent : BaseEvent public class CreateNewOtpTokenEvent : BaseEvent
{ {
public CreateNewOtpTokenEvent(OtpToken item) public CreateNewOtpTokenEvent(OtpToken item, string plainCode)
{ {
Item = item; Item = item;
PlainCode = plainCode;
} }
public OtpToken Item { get; } public OtpToken Item { get; }
/// <summary>
/// کد OTP به صورت plain text برای ارسال SMS
/// </summary>
public string PlainCode { get; }
} }
@@ -6,6 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.54.0" />
<PackageReference Include="Kavenegar" Version="1.2.5" /> <PackageReference Include="Kavenegar" Version="1.2.5" />
<PackageReference Include="MailKit" Version="4.14.1" /> <PackageReference Include="MailKit" Version="4.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.11" /> <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.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" />
<PackageReference Include="Polly" Version="8.5.0" /> <PackageReference Include="Polly" Version="8.5.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CMSMicroservice.Application\CMSMicroservice.Application.csproj" /> <ProjectReference Include="..\CMSMicroservice.Application\CMSMicroservice.Application.csproj" />
<ProjectReference Include="..\CMSMicroservice.Protobuf\CMSMicroservice.Protobuf.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,9 +1,11 @@
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Authorization;
using CMSMicroservice.Application.DayaLoanCQ.Services; using CMSMicroservice.Application.DayaLoanCQ.Services;
using CMSMicroservice.Infrastructure.Persistence; using CMSMicroservice.Infrastructure.Persistence;
using CMSMicroservice.Infrastructure.Persistence.Interceptors; using CMSMicroservice.Infrastructure.Persistence.Interceptors;
using CMSMicroservice.Infrastructure.BackgroundJobs; using CMSMicroservice.Infrastructure.BackgroundJobs;
using CMSMicroservice.Infrastructure.Services.Monitoring; using CMSMicroservice.Infrastructure.Services.Monitoring;
using CMSMicroservice.Infrastructure.Services.Authorization;
using CMSMicroservice.Infrastructure.Configuration; using CMSMicroservice.Infrastructure.Configuration;
using CMSMicroservice.Infrastructure.Services.Payment; using CMSMicroservice.Infrastructure.Services.Payment;
using CMSMicroservice.Infrastructure.Services.Commission; using CMSMicroservice.Infrastructure.Services.Commission;
@@ -35,6 +37,8 @@ public static class ConfigureServices
services.AddScoped<IAlertService, AlertService>(); services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IUserNotificationService, UserNotificationService>(); services.AddScoped<IUserNotificationService, UserNotificationService>();
services.AddScoped<IKavenegarService, KavenegarService>(); services.AddScoped<IKavenegarService, KavenegarService>();
services.AddScoped<IFileManagementService, FileManagementService>();
services.AddScoped<IPermissionService, PermissionService>();
// Daya Loan API Service - قابل تغییر بین Mock و Real // Daya Loan API Service - قابل تغییر بین Mock و Real
var useMockDayaApi = configuration.GetValue<bool>("DayaApi:UseMock", false); 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" /> <Protobuf Include="Protos\appversion.proto" ProtoRoot="Protos\" GrpcServices="Both" />
<!-- Inventory Management System --> <!-- Inventory Management System -->
<Protobuf Include="Protos\inventory.proto" ProtoRoot="Protos\" GrpcServices="Both" /> <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> </ItemGroup>
<Target Name="PushToFoursatNuget" AfterTargets="Pack" Condition="'$(CI)' != 'true'"> <Target Name="PushToFoursatNuget" AfterTargets="Pack" Condition="'$(CI)' != 'true'">
@@ -87,6 +87,8 @@ message CreateDiscountProductRequest
int32 sort_order = 9; int32 sort_order = 9;
bool is_active = 10; bool is_active = 10;
repeated int64 category_ids = 11; repeated int64 category_ids = 11;
ImageFileModel image_file = 12;
ImageFileModel thumbnail_file = 13;
} }
message CreateDiscountProductResponse message CreateDiscountProductResponse
@@ -108,6 +110,8 @@ message UpdateDiscountProductRequest
int32 sort_order = 9; int32 sort_order = 9;
bool is_active = 10; bool is_active = 10;
repeated int64 category_ids = 11; repeated int64 category_ids = 11;
ImageFileModel image_file = 12;
ImageFileModel thumbnail_file = 13;
} }
// Delete Product // Delete Product
@@ -246,3 +250,11 @@ message DiscountProductImageDto
int32 sort_order = 7; int32 sort_order = 7;
bool is_active = 8; 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; string product_title = 15;
int64 product_price = 16; int64 product_price = 16;
google.protobuf.Timestamp created = 17; google.protobuf.Timestamp created = 17;
// آیا موجودی کم است (موجودی قابل فروش کمتر از حد هشدار)
bool is_low_stock = 18;
} }
message GetInventoryItemRequest { message GetInventoryItemRequest {
@@ -78,6 +78,7 @@ message CreateManualPaymentRequest
google.protobuf.StringValue reference_number = 5; google.protobuf.StringValue reference_number = 5;
google.protobuf.StringValue image_path = 6; google.protobuf.StringValue image_path = 6;
google.protobuf.Int64Value image_document_id = 7; google.protobuf.Int64Value image_document_id = 7;
FileUploadModel image_file = 8;
} }
message CreateManualPaymentResponse message CreateManualPaymentResponse
@@ -155,3 +156,11 @@ message ProcessManualMembershipPaymentResponse
string message = 4; 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 description = 2;
string image_path = 3; string image_path = 3;
int64 price = 4; int64 price = 4;
BoostCardFileModel image_file = 5;
} }
message CreateNewPackageResponse message CreateNewPackageResponse
{ {
@@ -125,6 +126,7 @@ message UpdatePackageRequest
string description = 3; string description = 3;
string image_path = 4; string image_path = 4;
int64 price = 5; int64 price = 5;
BoostCardFileModel image_file = 6;
} }
message DeletePackageRequest message DeletePackageRequest
{ {
@@ -412,3 +414,11 @@ enum PaymentStatusEnum
PAYMENT_STATUS_FAILED = 2; PAYMENT_STATUS_FAILED = 2;
PAYMENT_STATUS_REFUNDED = 3; 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" 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 message CreateNewProductsRequest
{ {
@@ -96,6 +140,10 @@ message CreateNewProductsRequest
int32 remaining_count = 12; int32 remaining_count = 12;
// لیست شناسه دسته‌بندی‌های محصول // لیست شناسه دسته‌بندی‌های محصول
repeated int64 category_ids = 13; repeated int64 category_ids = 13;
// فایل تصویر اصلی محصول (آپلود باینری)
ImageFileModel image_file = 14;
// فایل تصویر بندانگشتی محصول (آپلود باینری)
ImageFileModel thumbnail_file = 15;
} }
message CreateNewProductsResponse message CreateNewProductsResponse
{ {
@@ -118,6 +166,10 @@ message UpdateProductsRequest
int32 remaining_count = 13; int32 remaining_count = 13;
// لیست شناسه دسته‌بندی‌های محصول // لیست شناسه دسته‌بندی‌های محصول
repeated int64 category_ids = 14; repeated int64 category_ids = 14;
// فایل تصویر اصلی محصول (آپلود باینری)
ImageFileModel image_file = 15;
// فایل تصویر بندانگشتی محصول (آپلود باینری)
ImageFileModel thumbnail_file = 16;
} }
message DeleteProductsRequest message DeleteProductsRequest
{ {
@@ -334,3 +386,97 @@ message ToggleProductStatusResponse
int32 failed = 3; int32 failed = 3;
repeated BulkOperationError errors = 4; 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;
}
@@ -160,9 +160,11 @@ message CustomerWalletChangeLogModel
int64 change_value = 2; int64 change_value = 2;
int64 current_network_balance = 3; int64 current_network_balance = 3;
int64 change_nerwork_value = 4; int64 change_nerwork_value = 4;
bool is_increase = 5; int64 current_discount_balance = 5;
google.protobuf.Int64Value refrence_id = 6; int64 change_discount_value = 6;
google.protobuf.Timestamp created_at = 7; bool is_increase = 7;
google.protobuf.Int64Value refrence_id = 8;
google.protobuf.Timestamp created_at = 9;
} }
message CustomerWithdrawBalanceRequest message CustomerWithdrawBalanceRequest
@@ -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);
};
}
@@ -1,4 +1,5 @@
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetAllClubMemberships; using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetAllClubMemberships;
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership;
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubStatistics; using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubStatistics;
using CMSMicroservice.Protobuf.Protos.ClubMembership; using CMSMicroservice.Protobuf.Protos.ClubMembership;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
@@ -11,6 +12,37 @@ public class ClubMembershipProfile : IRegister
{ {
public void Register(TypeAdapterConfig config) public void Register(TypeAdapterConfig config)
{ {
// =====================
// GetClubMembership
// =====================
// GetClubMembershipRequest -> GetClubMembershipQuery
config.NewConfig<GetClubMembershipRequest, GetClubMembershipQuery>()
.MapWith(src => new GetClubMembershipQuery
{
UserId = src.UserId
});
// ClubMembershipDto -> GetClubMembershipResponse
config.NewConfig<ClubMembershipDto, GetClubMembershipResponse>()
.MapWith(src => new GetClubMembershipResponse
{
Id = src.Id,
UserId = src.UserId,
PackageId = 0, // Not available
PackageName = string.Empty, // Not available
ActivationCode = string.Empty, // Not available
ActivatedAt = src.ActivatedAt.HasValue
? Timestamp.FromDateTime(DateTime.SpecifyKind(src.ActivatedAt.Value, DateTimeKind.Utc))
: null,
ExpiresAt = null, // Not available
IsActive = src.IsActive,
Created = Timestamp.FromDateTimeOffset(src.Created),
Features = { }, // Empty for now
Status = src.IsActive ? "Active" : "Inactive",
DaysRemaining = 0 // Calculate if ExpiresAt is available
});
// ===================== // =====================
// GetAllClubMemberships // GetAllClubMemberships
// ===================== // =====================
@@ -1,8 +1,12 @@
using System.Linq;
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances; using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
using CMSMicroservice.Application.CommissionCQ.Queries.GetMyWeeklyBalances;
using CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks; using CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks;
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayouts; using CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayouts;
using CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
using CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions; using CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions;
using CMSMicroservice.Application.Common.Models; using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Protobuf.Protos.Commission; using CMSMicroservice.Protobuf.Protos.Commission;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Mapster; using Mapster;
@@ -124,5 +128,77 @@ public class CommissionProfile : IRegister
: null) : null)
.Map(dest => dest.IsExpired, src => src.IsExpired) .Map(dest => dest.IsExpired, src => src.IsExpired)
.Map(dest => dest.Created, src => Timestamp.FromDateTimeOffset(src.Created)); .Map(dest => dest.Created, src => Timestamp.FromDateTimeOffset(src.Created));
// GetMyCommissionPayouts Request Mapping
config.NewConfig<GetMyCommissionPayoutsRequest, GetMyCommissionPayoutsQuery>()
.Map(dest => dest.Status, src => src.Status != null && src.Status.Value >= 0
? (CommissionPayoutStatus?)src.Status.Value
: null)
.Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId != null ? src.WeekDefinitionId.Value : (long?)null)
.Map(dest => dest.PaginationState, src => new PaginationState
{
PageNumber = src.PageNumber > 0 ? src.PageNumber : 1,
PageSize = src.PageSize > 0 ? src.PageSize : 10
});
// GetMyCommissionPayouts Response Mapping
config.NewConfig<GetMyCommissionPayoutsResponseDto, GetMyCommissionPayoutsResponse>()
.Map(dest => dest.MetaData, src => src.MetaData != null
? new CustomerMetaData { TotalCount = src.MetaData.TotalCount }
: new CustomerMetaData())
.Map(dest => dest.Payouts, src => src.Models);
// CustomerCommissionPayoutModel Mapping
config.NewConfig<GetMyCommissionPayoutsResponseModel, CustomerCommissionPayoutModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId)
.Map(dest => dest.WeekDisplayName, src => src.WeekDisplayName)
.Map(dest => dest.BalancesEarned, src => src.BalancesEarned)
.Map(dest => dest.TotalAmount, src => src.TotalAmount)
.Map(dest => dest.AmountFormatted, src => src.AmountFormatted)
.Map(dest => dest.Status, src => (int)src.Status)
.Map(dest => dest.CalculatedDate, src => src.CalculatedDate.HasValue
? Timestamp.FromDateTime(src.CalculatedDate.Value.ToUniversalTime())
: null)
.Map(dest => dest.DatePersian, src => src.DatePersian);
// GetMyWeeklyBalances Request Mapping
config.NewConfig<GetMyWeeklyBalancesRequest, GetMyWeeklyBalancesQuery>()
.Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId != null ? src.WeekDefinitionId.Value : (long?)null)
.Map(dest => dest.OnlyActive, src => src.OnlyActive)
.Map(dest => dest.PaginationState, src => new PaginationState
{
PageNumber = src.PageNumber > 0 ? src.PageNumber : 1,
PageSize = src.PageSize > 0 ? src.PageSize : 10
});
// GetMyWeeklyBalances Response Mapping
config.NewConfig<GetUserWeeklyBalancesResponseDto, GetMyWeeklyBalancesResponse>()
.Map(dest => dest.MetaData, src => src.MetaData != null
? new CustomerMetaData { TotalCount = src.MetaData.TotalCount }
: new CustomerMetaData())
.Map(dest => dest.Balances, src => src.Models)
.Map(dest => dest.TotalLeftBalances, src => src.Models.Sum(m => m.LeftLegTotal))
.Map(dest => dest.TotalRightBalances, src => src.Models.Sum(m => m.RightLegTotal))
.Map(dest => dest.WeakerLeg, src =>
src.Models.Sum(m => m.LeftLegTotal) < src.Models.Sum(m => m.RightLegTotal)
? "Left"
: src.Models.Sum(m => m.LeftLegTotal) > src.Models.Sum(m => m.RightLegTotal)
? "Right"
: "Equal");
// CustomerWeeklyBalanceModel Mapping
config.NewConfig<GetUserWeeklyBalancesResponseModel, CustomerWeeklyBalanceModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.WeekDefinitionId, src => src.WeekDefinitionId)
.Map(dest => dest.WeekDisplayName, src => src.WeekDisplayName)
.Map(dest => dest.LeftLegBalances, src => src.LeftLegTotal)
.Map(dest => dest.RightLegBalances, src => src.RightLegTotal)
.Map(dest => dest.TotalBalances, src => src.TotalBalances)
.Map(dest => dest.WeeklyPoolContribution, src => src.WeeklyPoolContribution)
.Map(dest => dest.IsExpired, src => src.IsExpired)
.Map(dest => dest.CalculatedAt, src => src.CalculatedAt.HasValue
? Timestamp.FromDateTime(DateTime.SpecifyKind(src.CalculatedAt.Value, DateTimeKind.Utc))
: null);
} }
} }
@@ -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<LoggingBehaviour>();
options.Interceptors.Add<PerformanceBehaviour>(); options.Interceptors.Add<PerformanceBehaviour>();
options.Interceptors.Add<CMSMicroservice.WebApi.Interceptors.PermissionInterceptor>();
//options.Interceptors.Add<ExceptionHandlingBehaviour>(); //options.Interceptors.Add<ExceptionHandlingBehaviour>();
options.EnableDetailedErrors = true; options.EnableDetailedErrors = true;
options.MaxReceiveMessageSize = 1000 * 1024 * 1024; // 1 GB 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 // Map SignalR Hub for token notifications
app.MapHub<TokenNotificationHub>("/hubs/token-notification"); app.MapHub<TokenNotificationHub>("/hubs/token-notification");
app.MapHub<TokenNotificationHub>("/hubs/token-relay"); // Alias for FrontOffice backward compatibility
app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints => app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints =>
{ {
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Authorization;
using CMSMicroservice.Protobuf.Protos.AppVersion; using CMSMicroservice.Protobuf.Protos.AppVersion;
using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion; using CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
@@ -15,16 +16,19 @@ public class AppVersionService : AppVersionContract.AppVersionContractBase
_dispatchRequestToCQRS = dispatchRequestToCQRS; _dispatchRequestToCQRS = dispatchRequestToCQRS;
} }
[RequiresPermission(PermissionNames.SettingsView)]
public override async Task<GetAppVersionResponse> GetAppVersion(GetAppVersionRequest request, ServerCallContext context) public override async Task<GetAppVersionResponse> GetAppVersion(GetAppVersionRequest request, ServerCallContext context)
{ {
return await _dispatchRequestToCQRS.Handle<GetAppVersionRequest, GetAppVersionQuery, GetAppVersionResponse>(request, context); return await _dispatchRequestToCQRS.Handle<GetAppVersionRequest, GetAppVersionQuery, GetAppVersionResponse>(request, context);
} }
[RequiresPermission(PermissionNames.SettingsManageConfiguration)]
public override async Task<Empty> UpdateAppVersion(UpdateAppVersionRequest request, ServerCallContext context) public override async Task<Empty> UpdateAppVersion(UpdateAppVersionRequest request, ServerCallContext context)
{ {
return await _dispatchRequestToCQRS.Handle<UpdateAppVersionRequest, UpdateAppVersionCommand>(request, context); return await _dispatchRequestToCQRS.Handle<UpdateAppVersionRequest, UpdateAppVersionCommand>(request, context);
} }
[RequiresPermission(PermissionNames.SettingsView)]
public override async Task<GetAllAppVersionsResponse> GetAllAppVersions(GetAllAppVersionsRequest request, ServerCallContext context) public override async Task<GetAllAppVersionsResponse> GetAllAppVersions(GetAllAppVersionsRequest request, ServerCallContext context)
{ {
return await _dispatchRequestToCQRS.Handle<GetAllAppVersionsRequest, GetAllAppVersionsQuery, GetAllAppVersionsResponse>(request, context); return await _dispatchRequestToCQRS.Handle<GetAllAppVersionsRequest, GetAllAppVersionsQuery, GetAllAppVersionsResponse>(request, context);
@@ -5,14 +5,19 @@ using CMSMicroservice.Application.CategoryCQ.Commands.UpdateCategory;
using CMSMicroservice.Application.CategoryCQ.Commands.DeleteCategory; using CMSMicroservice.Application.CategoryCQ.Commands.DeleteCategory;
using CMSMicroservice.Application.CategoryCQ.Queries.GetCategory; using CMSMicroservice.Application.CategoryCQ.Queries.GetCategory;
using CMSMicroservice.Application.CategoryCQ.Queries.GetAllCategoryByFilter; using CMSMicroservice.Application.CategoryCQ.Queries.GetAllCategoryByFilter;
using MediatR;
using AppModels = CMSMicroservice.Application.Common.Models;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
public class CategoryService : CategoryContract.CategoryContractBase public class CategoryService : CategoryContract.CategoryContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly ISender _sender;
public CategoryService(IDispatchRequestToCQRS dispatchRequestToCQRS) public CategoryService(IDispatchRequestToCQRS dispatchRequestToCQRS, ISender sender)
{ {
_dispatchRequestToCQRS = dispatchRequestToCQRS; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender;
} }
public override async Task<CreateNewCategoryResponse> CreateNewCategory(CreateNewCategoryRequest request, ServerCallContext context) public override async Task<CreateNewCategoryResponse> CreateNewCategory(CreateNewCategoryRequest request, ServerCallContext context)
{ {
@@ -45,25 +50,72 @@ public class CategoryService : CategoryContract.CategoryContractBase
public override async Task<GetAllCategoriesForCustomerResponse> GetAllCategoriesForCustomer(GetAllCategoriesForCustomerRequest request, ServerCallContext context) public override async Task<GetAllCategoriesForCustomerResponse> GetAllCategoriesForCustomer(GetAllCategoriesForCustomerRequest request, ServerCallContext context)
{ {
// TODO: Implement using existing CMS Category Application layer // Use GetAllCategoryByFilter to get all active categories
// For now, return empty response var query = new GetAllCategoryByFilterQuery
return new GetAllCategoriesForCustomerResponse {
Filter = new CMSMicroservice.Application.CategoryCQ.Queries.GetAllCategoryByFilter.GetAllCategoryByFilterFilter
{
IsActive = true // Only active categories for customers
},
PaginationState = new AppModels.PaginationState
{
PageNumber = request.PageNumber > 0 ? request.PageNumber : 1,
PageSize = request.PageSize > 0 ? request.PageSize : 100 // Default to large page size to get all
},
SortBy = "SortOrder" // Sort by display order
};
var result = await _sender.Send(query, context.CancellationToken);
var response = new GetAllCategoriesForCustomerResponse
{ {
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{ {
CurrentPage = request.PageNumber, CurrentPage = result.MetaData.CurrentPage,
PageSize = request.PageSize, PageSize = result.MetaData.PageSize,
TotalCount = 0, TotalCount = result.MetaData.TotalCount,
TotalPage = 0, TotalPage = result.MetaData.TotalPage,
HasNext = false, HasNext = result.MetaData.HasNext,
HasPrevious = false HasPrevious = result.MetaData.HasPrevious
} }
}; };
foreach (var cat in result.Models)
{
response.Models.Add(new GetAllCategoryFilterResponseModel
{
Id = cat.Id,
Name = cat.Name,
Title = cat.Title,
Description = cat.Description ?? string.Empty,
ImagePath = cat.ImagePath ?? string.Empty,
ParentId = cat.ParentId ?? 0,
IsActive = cat.IsActive,
SortOrder = cat.SortOrder
});
}
return response;
} }
public override async Task<GetCategoryByIdForCustomerResponse> GetCategoryByIdForCustomer(GetCategoryByIdForCustomerRequest request, ServerCallContext context) public override async Task<GetCategoryByIdForCustomerResponse> GetCategoryByIdForCustomer(GetCategoryByIdForCustomerRequest request, ServerCallContext context)
{ {
// TODO: Implement using existing CMS Category Application layer var query = new GetCategoryQuery { Id = request.Id };
throw new RpcException(new Status(StatusCode.Unimplemented, "GetCategoryByIdForCustomer not implemented yet")); 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.Protobuf.Protos.City;
using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter; 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; namespace CMSMicroservice.WebApi.Services;
public class CityService : CityContract.CityContractBase public class CityService : CityContract.CityContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly IApplicationDbContext _context;
public CityService(IDispatchRequestToCQRS dispatchRequestToCQRS) public CityService(IDispatchRequestToCQRS dispatchRequestToCQRS, IApplicationDbContext context)
{ {
_dispatchRequestToCQRS = dispatchRequestToCQRS; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_context = context;
} }
public override async Task<GetAllCitiesByFilterResponse> GetAllCitiesByFilter( public override async Task<GetAllCitiesByFilterResponse> GetAllCitiesByFilter(
@@ -28,18 +34,50 @@ public class CityService : CityContract.CityContractBase
public override async Task<GetCitiesForCustomerResponse> GetCitiesForCustomer( public override async Task<GetCitiesForCustomerResponse> GetCitiesForCustomer(
GetCitiesForCustomerRequest request, ServerCallContext context) GetCitiesForCustomerRequest request, ServerCallContext context)
{ {
// TODO: Implement using existing CMS City Application layer var pageNumber = request.PageNumber > 0 ? request.PageNumber : 1;
// For now, return empty response 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 return new GetCitiesForCustomerResponse
{ {
Cities = { cities },
MetaData = new CMSMicroservice.Protobuf.Protos.City.MetaData MetaData = new CMSMicroservice.Protobuf.Protos.City.MetaData
{ {
CurrentPage = request.PageNumber, CurrentPage = pageNumber,
PageSize = request.PageSize, PageSize = pageSize,
TotalCount = 0, TotalCount = totalCount,
TotalPage = 0, TotalPage = (int)Math.Ceiling(totalCount / (double)pageSize),
HasNext = false, HasNext = pageNumber * pageSize < totalCount,
HasPrevious = false HasPrevious = pageNumber > 1
} }
}; };
} }
@@ -47,34 +85,126 @@ public class CityService : CityContract.CityContractBase
public override async Task<GetCityByIdForCustomerResponse> GetCityByIdForCustomer( public override async Task<GetCityByIdForCustomerResponse> GetCityByIdForCustomer(
GetCityByIdForCustomerRequest request, ServerCallContext context) GetCityByIdForCustomerRequest request, ServerCallContext context)
{ {
// TODO: Implement using existing CMS City Application layer var city = await _context.Cities
throw new RpcException(new Status(StatusCode.Unimplemented, "GetCityByIdForCustomer not implemented yet")); .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( public override async Task<GetCitiesByStateForCustomerResponse> GetCitiesByStateForCustomer(
GetCitiesByStateForCustomerRequest request, ServerCallContext context) GetCitiesByStateForCustomerRequest request, ServerCallContext context)
{ {
// TODO: Implement using existing CMS City Application layer var pageNumber = request.PageNumber > 0 ? request.PageNumber : 1;
throw new RpcException(new Status(StatusCode.Unimplemented, "GetCitiesByStateForCustomer not implemented yet")); 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( public override async Task<CreateCityResponse> CreateCity(
CreateCityRequest request, ServerCallContext context) 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( public override async Task<Empty> UpdateCity(
UpdateCityRequest request, ServerCallContext context) 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( public override async Task<Empty> DeleteCity(
DeleteCityRequest request, ServerCallContext context) 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 #endregion
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Protobuf.Protos.ClubMembership; using CMSMicroservice.Protobuf.Protos.ClubMembership;
using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership; using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
@@ -10,16 +11,24 @@ using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHist
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubStatistics; using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubStatistics;
using CMSMicroservice.Application.ClubFeatureCQ.Queries.GetUserClubFeatures; using CMSMicroservice.Application.ClubFeatureCQ.Queries.GetUserClubFeatures;
using CMSMicroservice.Application.ClubFeatureCQ.Commands.ToggleUserClubFeature; using CMSMicroservice.Application.ClubFeatureCQ.Commands.ToggleUserClubFeature;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
public class ClubMembershipService : ClubMembershipContract.ClubMembershipContractBase public class ClubMembershipService : ClubMembershipContract.ClubMembershipContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly ICurrentUserService _currentUserService;
private readonly ILogger<ClubMembershipService> _logger;
public ClubMembershipService(IDispatchRequestToCQRS dispatchRequestToCQRS) public ClubMembershipService(
IDispatchRequestToCQRS dispatchRequestToCQRS,
ICurrentUserService currentUserService,
ILogger<ClubMembershipService> logger)
{ {
_dispatchRequestToCQRS = dispatchRequestToCQRS; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_currentUserService = currentUserService;
_logger = logger;
} }
public override async Task<Empty> ActivateClubMembership(ActivateClubMembershipRequest request, ServerCallContext context) public override async Task<Empty> ActivateClubMembership(ActivateClubMembershipRequest request, ServerCallContext context)
@@ -39,6 +48,25 @@ public class ClubMembershipService : ClubMembershipContract.ClubMembershipContra
public override async Task<GetClubMembershipResponse> GetClubMembership(GetClubMembershipRequest request, ServerCallContext context) public override async Task<GetClubMembershipResponse> GetClubMembership(GetClubMembershipRequest request, ServerCallContext context)
{ {
// اگر UserId در request نیست یا صفر است، از JWT بخوان
if (request.UserId <= 0)
{
if (long.TryParse(_currentUserService.UserId, out var tokenUserId) && tokenUserId > 0)
{
_logger.LogInformation("GetClubMembership: Reading UserId from JWT token: {UserId}", tokenUserId);
request = new GetClubMembershipRequest { UserId = tokenUserId };
}
else
{
_logger.LogWarning("GetClubMembership: No valid UserId in request or JWT token");
throw new RpcException(new Status(StatusCode.Unauthenticated, "کاربر احراز هویت نشده است"));
}
}
else
{
_logger.LogInformation("GetClubMembership: Using UserId from request: {UserId}", request.UserId);
}
return await _dispatchRequestToCQRS.Handle<GetClubMembershipRequest, GetClubMembershipQuery, GetClubMembershipResponse>(request, context); return await _dispatchRequestToCQRS.Handle<GetClubMembershipRequest, GetClubMembershipQuery, GetClubMembershipResponse>(request, context);
} }
@@ -12,6 +12,7 @@ using CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPool;
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayouts; using CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayouts;
using CMSMicroservice.Application.CommissionCQ.Queries.GetCommissionPayoutHistory; using CMSMicroservice.Application.CommissionCQ.Queries.GetCommissionPayoutHistory;
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances; using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
using CMSMicroservice.Application.CommissionCQ.Queries.GetMyWeeklyBalances;
using CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools; using CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
using CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalRequests; using CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalRequests;
using CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerStatus; using CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerStatus;
@@ -19,6 +20,7 @@ using CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerExecutionLogs;
using CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalReports; using CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalReports;
using CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks; using CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks;
using CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions; using CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions;
using CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
@@ -68,6 +70,11 @@ public class CommissionService : CommissionContract.CommissionContractBase
return await _dispatchRequestToCQRS.Handle<GetUserCommissionPayoutsRequest, GetUserCommissionPayoutsQuery, GetUserCommissionPayoutsResponse>(request, context); return await _dispatchRequestToCQRS.Handle<GetUserCommissionPayoutsRequest, GetUserCommissionPayoutsQuery, GetUserCommissionPayoutsResponse>(request, context);
} }
public override async Task<GetMyCommissionPayoutsResponse> GetMyCommissionPayouts(GetMyCommissionPayoutsRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetMyCommissionPayoutsRequest, GetMyCommissionPayoutsQuery, GetMyCommissionPayoutsResponse>(request, context);
}
public override async Task<GetCommissionPayoutHistoryResponse> GetCommissionPayoutHistory(GetCommissionPayoutHistoryRequest request, ServerCallContext context) public override async Task<GetCommissionPayoutHistoryResponse> GetCommissionPayoutHistory(GetCommissionPayoutHistoryRequest request, ServerCallContext context)
{ {
return await _dispatchRequestToCQRS.Handle<GetCommissionPayoutHistoryRequest, GetCommissionPayoutHistoryQuery, GetCommissionPayoutHistoryResponse>(request, context); return await _dispatchRequestToCQRS.Handle<GetCommissionPayoutHistoryRequest, GetCommissionPayoutHistoryQuery, GetCommissionPayoutHistoryResponse>(request, context);
@@ -128,4 +135,9 @@ public class CommissionService : CommissionContract.CommissionContractBase
{ {
return await _dispatchRequestToCQRS.Handle<GetWithdrawalReportsRequest, GetWithdrawalReportsQuery, GetWithdrawalReportsResponse>(request, context); return await _dispatchRequestToCQRS.Handle<GetWithdrawalReportsRequest, GetWithdrawalReportsQuery, GetWithdrawalReportsResponse>(request, context);
} }
public override async Task<GetMyWeeklyBalancesResponse> GetMyWeeklyBalances(GetMyWeeklyBalancesRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<GetMyWeeklyBalancesRequest, GetMyWeeklyBalancesQuery, GetMyWeeklyBalancesResponse>(request, context);
}
} }
@@ -1,7 +1,11 @@
using CMSMicroservice.Application.ClubFeatureCQ.Queries.GetUserClubFeatures;
using CMSMicroservice.Application.Common.Authorization;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
using CMSMicroservice.Protobuf.Protos.Configuration; using CMSMicroservice.Protobuf.Protos.Configuration;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
using MediatR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
@@ -12,10 +16,17 @@ namespace CMSMicroservice.WebApi.Services;
public class ConfigurationService : ConfigurationContract.ConfigurationContractBase public class ConfigurationService : ConfigurationContract.ConfigurationContractBase
{ {
private readonly ILogger<ConfigurationService> _logger; private readonly ILogger<ConfigurationService> _logger;
private readonly ICurrentUserService _currentUserService;
private readonly IMediator _mediator;
public ConfigurationService(ILogger<ConfigurationService> logger) public ConfigurationService(
ILogger<ConfigurationService> logger,
ICurrentUserService currentUserService,
IMediator mediator)
{ {
_logger = logger; _logger = logger;
_currentUserService = currentUserService;
_mediator = mediator;
} }
/// <summary> /// <summary>
@@ -41,9 +52,65 @@ public class ConfigurationService : ConfigurationContract.ConfigurationContractB
return Task.FromResult(response); return Task.FromResult(response);
} }
/// <summary>
/// دریافت تنظیمات باشگاه مشتریان
/// </summary>
public override Task<GetClubConfigurationResponse> GetClubConfiguration(Empty request, ServerCallContext context)
{
var response = new GetClubConfigurationResponse
{
ActivationFee = SystemConstants.ClubActivationFee,
MembershipGiftValue = SystemConstants.ClubMembershipGiftValue
};
_logger.LogDebug("Club configuration requested: ActivationFee={ActivationFee}, GiftValue={GiftValue}",
response.ActivationFee, response.MembershipGiftValue);
return Task.FromResult(response);
}
/// <summary>
/// دریافت ویژگی‌های باشگاه مشتریان برای کاربر جاری
/// </summary>
public override async Task<GetClubFeaturesResponse> GetClubFeatures(Empty request, ServerCallContext context)
{
// دریافت UserId از JWT
if (!long.TryParse(_currentUserService.UserId, out var userId) || userId <= 0)
{
_logger.LogWarning("GetClubFeatures called without valid user authentication");
return new GetClubFeaturesResponse(); // لیست خالی برای کاربران غیر احراز هویت شده
}
// فراخوانی GetUserClubFeatures از طریق MediatR (CQRS داخلی)
var query = new GetUserClubFeaturesQuery { UserId = userId };
var userFeatures = await _mediator.Send(query, context.CancellationToken);
// تبدیل به فرمت GetClubFeaturesResponse
var response = new GetClubFeaturesResponse();
foreach (var feature in userFeatures)
{
response.Features.Add(new ClubFeatureModel
{
Id = feature.ClubFeatureId,
Title = feature.FeatureTitle ?? string.Empty,
Description = feature.FeatureDescription ?? string.Empty,
IsEnabled = feature.IsActive,
DisplayOrder = feature.SortOrder,
GrantedAt = Timestamp.FromDateTime(DateTime.SpecifyKind(feature.GrantedAt, DateTimeKind.Utc)),
CreatedAt = Timestamp.FromDateTime(DateTime.SpecifyKind(feature.CreatedAt, DateTimeKind.Utc)),
Notes = feature.Notes ?? string.Empty
});
}
_logger.LogDebug("Club features requested for user {UserId}: {Count} features returned", userId, response.Features.Count);
return response;
}
/// <summary> /// <summary>
/// دریافت تمام تنظیمات /// دریافت تمام تنظیمات
/// </summary> /// </summary>
[RequiresPermission(PermissionNames.SettingsView)]
public override Task<GetAllConfigurationsResponse> GetAllConfigurations(GetAllConfigurationsRequest request, ServerCallContext context) public override Task<GetAllConfigurationsResponse> GetAllConfigurations(GetAllConfigurationsRequest request, ServerCallContext context)
{ {
var response = new GetAllConfigurationsResponse(); var response = new GetAllConfigurationsResponse();
@@ -80,16 +147,18 @@ public class ConfigurationService : ConfigurationContract.ConfigurationContractB
/// <summary> /// <summary>
/// سایر عملیات‌ها که فعلاً پیاده‌سازی نشده‌اند (چون از constant استفاده می‌کنیم) /// سایر عملیات‌ها که فعلاً پیاده‌سازی نشده‌اند (چون از constant استفاده می‌کنیم)
/// </summary> /// </summary>
[RequiresPermission(PermissionNames.SettingsManageConfiguration)]
public override Task<Empty> CreateOrUpdateConfiguration(CreateOrUpdateConfigurationRequest request, ServerCallContext context) public override Task<Empty> CreateOrUpdateConfiguration(CreateOrUpdateConfigurationRequest request, ServerCallContext context)
{ {
_logger.LogWarning("CreateOrUpdateConfiguration called but SystemConstants are read-only"); _logger.LogWarning("CreateOrUpdateConfiguration called but SystemConstants are read-only. Key: {Key}", request.Key);
throw new RpcException(new Status(StatusCode.Unimplemented, "تنظیمات سیستم فقط خواندنی هستند")); throw new RpcException(new Status(StatusCode.FailedPrecondition, "تنظیمات سیستم فقط خواندنی هستند. تغییر از طریق کد انجام می‌شود"));
} }
[RequiresPermission(PermissionNames.SettingsManageConfiguration)]
public override Task<Empty> DeactivateConfiguration(DeactivateConfigurationRequest request, ServerCallContext context) public override Task<Empty> DeactivateConfiguration(DeactivateConfigurationRequest request, ServerCallContext context)
{ {
_logger.LogWarning("DeactivateConfiguration called but SystemConstants are read-only"); _logger.LogWarning("DeactivateConfiguration called but SystemConstants are read-only. Key: {Key}", request.Key);
throw new RpcException(new Status(StatusCode.Unimplemented, "تنظیمات سیستم فقط خواندنی هستند")); throw new RpcException(new Status(StatusCode.FailedPrecondition, "تنظیمات سیستم فقط خواندنی هستند. غیرفعال‌سازی از طریق کد انجام می‌شود"));
} }
public override Task<GetConfigurationHistoryResponse> GetConfigurationHistory(GetConfigurationHistoryRequest request, ServerCallContext context) 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.Commands.CreateStockMovement;
using CMSMicroservice.Application.StockMovementCQ.Queries.GetStockMovements; using CMSMicroservice.Application.StockMovementCQ.Queries.GetStockMovements;
using CMSMicroservice.Application.StockMovementCQ.Queries.GetStockMovementsByInventoryItem; using CMSMicroservice.Application.StockMovementCQ.Queries.GetStockMovementsByInventoryItem;
using CMSMicroservice.Application.Common.Interfaces;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
@@ -28,11 +31,13 @@ public class InventoryService : InventoryContract.InventoryContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IApplicationDbContext _context;
public InventoryService(IDispatchRequestToCQRS dispatchRequestToCQRS, IMediator mediator) public InventoryService(IDispatchRequestToCQRS dispatchRequestToCQRS, IMediator mediator, IApplicationDbContext context)
{ {
_dispatchRequestToCQRS = dispatchRequestToCQRS; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_mediator = mediator; _mediator = mediator;
_context = context;
} }
// ========== Warehouse Management ========== // ========== 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 var inventoryItem = await _mediator.Send(
throw new RpcException(new Status(StatusCode.Unimplemented, "ReserveStock requires product lookup - not yet implemented")); 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 var inventoryItem = await _mediator.Send(
throw new RpcException(new Status(StatusCode.Unimplemented, "ReleaseReservation requires product lookup - not yet implemented")); 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 var inventoryItem = await _mediator.Send(
throw new RpcException(new Status(StatusCode.Unimplemented, "ConfirmSale requires product lookup - not yet implemented")); 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 var inventoryItem = await _mediator.Send(
throw new RpcException(new Status(StatusCode.Unimplemented, "ProcessReturn requires product lookup - not yet implemented")); 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) public override async Task<Empty> RecordLoss(RecordLossRequest request, ServerCallContext context)
@@ -255,10 +341,48 @@ public class InventoryService : InventoryContract.InventoryContractBase
// ========== Bulk Operations ========== // ========== 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 var response = new BulkAddStockResponse();
throw new RpcException(new Status(StatusCode.Unimplemented, "BulkAddStock requires product lookup - not yet implemented"));
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 ========== // ========== Stock Movements ==========
@@ -275,28 +399,74 @@ public class InventoryService : InventoryContract.InventoryContractBase
// ========== Reports ========== // ========== Reports ==========
public override Task<GetInventorySummaryResponse> GetInventorySummary(GetInventorySummaryRequest request, ServerCallContext context) public override async Task<GetInventorySummaryResponse> GetInventorySummary(GetInventorySummaryRequest request, ServerCallContext context)
{ {
// TODO: Implement summary query var query = _context.InventoryItems
return Task.FromResult(new GetInventorySummaryResponse .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, long unitPrice = i.Product?.Price ?? i.DiscountProduct?.Price ?? 0;
TotalDiscountProducts = 0, return (long)i.Quantity * unitPrice;
TotalQuantity = 0,
TotalReserved = 0,
LowStockCount = 0,
OutOfStockCount = 0,
TotalStockValue = 0
}); });
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 var query = _context.InventoryItems
return Task.FromResult(new GetStockValueReportResponse .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, long unitPrice = i.Product?.Price ?? i.DiscountProduct?.Price ?? 0;
TotalItems = 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.Protobuf.Protos.ManualPayment;
using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.ManualPaymentCQ.Commands.CreateManualPayment; using CMSMicroservice.Application.ManualPaymentCQ.Commands.CreateManualPayment;
@@ -24,6 +25,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
_sender = sender; _sender = sender;
} }
[RequiresPermission(PermissionNames.ManualPaymentsCreate)]
public override async Task<CreateManualPaymentResponse> CreateManualPayment( public override async Task<CreateManualPaymentResponse> CreateManualPayment(
CreateManualPaymentRequest request, CreateManualPaymentRequest request,
ServerCallContext context) ServerCallContext context)
@@ -37,6 +39,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
}; };
} }
[RequiresPermission(PermissionNames.ManualPaymentsApprove)]
public override async Task<Google.Protobuf.WellKnownTypes.Empty> ApproveManualPayment( public override async Task<Google.Protobuf.WellKnownTypes.Empty> ApproveManualPayment(
ApproveManualPaymentRequest request, ApproveManualPaymentRequest request,
ServerCallContext context) ServerCallContext context)
@@ -44,6 +47,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
return await _dispatchRequestToCQRS.Handle<ApproveManualPaymentRequest, ApproveManualPaymentCommand>(request, context); return await _dispatchRequestToCQRS.Handle<ApproveManualPaymentRequest, ApproveManualPaymentCommand>(request, context);
} }
[RequiresPermission(PermissionNames.ManualPaymentsApprove)]
public override async Task<Google.Protobuf.WellKnownTypes.Empty> RejectManualPayment( public override async Task<Google.Protobuf.WellKnownTypes.Empty> RejectManualPayment(
RejectManualPaymentRequest request, RejectManualPaymentRequest request,
ServerCallContext context) ServerCallContext context)
@@ -51,6 +55,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
return await _dispatchRequestToCQRS.Handle<RejectManualPaymentRequest, RejectManualPaymentCommand>(request, context); return await _dispatchRequestToCQRS.Handle<RejectManualPaymentRequest, RejectManualPaymentCommand>(request, context);
} }
[RequiresPermission(PermissionNames.ManualPaymentsView)]
public override async Task<GetAllManualPaymentsResponse> GetAllManualPayments( public override async Task<GetAllManualPaymentsResponse> GetAllManualPayments(
GetAllManualPaymentsRequest request, GetAllManualPaymentsRequest request,
ServerCallContext context) ServerCallContext context)
@@ -58,6 +63,7 @@ public class ManualPaymentService : ManualPaymentContract.ManualPaymentContractB
return await _dispatchRequestToCQRS.Handle<GetAllManualPaymentsRequest, GetAllManualPaymentsQuery, GetAllManualPaymentsResponse>(request, context); return await _dispatchRequestToCQRS.Handle<GetAllManualPaymentsRequest, GetAllManualPaymentsQuery, GetAllManualPaymentsResponse>(request, context);
} }
[RequiresPermission(PermissionNames.ManualPaymentsCreate)]
public override async Task<ProcessManualMembershipPaymentResponse> ProcessManualMembershipPayment( public override async Task<ProcessManualMembershipPaymentResponse> ProcessManualMembershipPayment(
ProcessManualMembershipPaymentRequest request, ProcessManualMembershipPaymentRequest request,
ServerCallContext context) ServerCallContext context)
@@ -13,11 +13,14 @@ using CMSMicroservice.Application.PackageCQ.Queries.GetUserPackageStatus;
using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackages; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackages;
using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackageDetails; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackageDetails;
using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPurchaseHistory; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPurchaseHistory;
using CMSMicroservice.Application.Common.Interfaces;
using AppModels = CMSMicroservice.Application.Common.Models; using AppModels = CMSMicroservice.Application.Common.Models;
using Grpc.Core; using Grpc.Core;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using CMSMicroservice.Protobuf.Protos; using CMSMicroservice.Protobuf.Protos;
using Microsoft.EntityFrameworkCore;
using MediatR; using MediatR;
using Mapster; using Mapster;
@@ -26,11 +29,22 @@ public class PackageService : PackageContract.PackageContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly ISender _sender; 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; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender; _sender = sender;
_context = context;
_currentUserService = currentUserService;
_paymentGateway = paymentGateway;
} }
public override async Task<CreateNewPackageResponse> CreateNewPackage(CreateNewPackageRequest request, ServerCallContext context) 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) public override async Task<CustomerPurchasePackageResponse> CustomerPurchasePackage(CustomerPurchasePackageRequest request, ServerCallContext context)
{ {
// Mock Customer package purchase with realistic Persian response var userId = GetCurrentUserId();
var orderId = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var authority = "A" + orderId.ToString("D19"); // 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 return new CustomerPurchasePackageResponse
{ {
Success = true, Success = true,
Message = "درخواست خرید پکیج با موفقیت ثبت شد", Message = "درخواست خرید پکیج با موفقیت ثبت شد",
OrderId = orderId, OrderId = purchase.Id,
PaymentGatewayUrl = $"https://payment.gateway.com/payment?authority={authority}&amount=5600000", PaymentGatewayUrl = paymentResult.GatewayUrl ?? string.Empty,
Authority = authority Authority = paymentResult.RefId ?? string.Empty
}; };
} }
public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase(CustomerVerifyPackagePurchaseRequest request, ServerCallContext context) public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase(CustomerVerifyPackagePurchaseRequest request, ServerCallContext context)
{ {
// Mock Customer purchase verification with realistic Persian data // Find purchase record
var transactionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var purchase = await _context.UserPackagePurchases
var referenceCode = "REF" + transactionId.ToString(); .Include(p => p.Package)
.Where(p => p.Id == request.OrderId && !p.IsDeleted)
.FirstOrDefaultAsync(context.CancellationToken);
var isSuccessful = request.Status == "OK"; if (purchase == null)
throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد"));
// Find the associated transaction
var transaction = purchase.TransactionId.HasValue
? await _context.Transactions
.Where(t => t.Id == purchase.TransactionId.Value)
.FirstOrDefaultAsync(context.CancellationToken)
: null;
// If status from gateway callback is not OK
if (request.Status != "OK")
{
if (transaction != null)
{
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
await _context.SaveChangesAsync(context.CancellationToken);
}
return new CustomerVerifyPackagePurchaseResponse
{
Success = false,
Message = "پرداخت توسط کاربر لغو شد"
};
}
// Verify with payment gateway
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority, request.Status, context.CancellationToken);
if (verifyResult.IsSuccess && transaction != null)
{
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success;
transaction.PaymentDate = DateTime.UtcNow;
transaction.RefId = verifyResult.RefId;
}
else if (transaction != null)
{
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
}
await _context.SaveChangesAsync(context.CancellationToken);
return new CustomerVerifyPackagePurchaseResponse return new CustomerVerifyPackagePurchaseResponse
{ {
Success = isSuccessful, Success = verifyResult.IsSuccess,
Message = isSuccessful ? "خرید پکیج با موفقیت تایید شد" : "خرید پکیج ناموفق بود", Message = verifyResult.IsSuccess ? "خرید پکیج با موفقیت تایید شد" : (verifyResult.Message ?? "خرید پکیج ناموفق بود"),
TransactionId = transactionId, TransactionId = transaction?.Id ?? 0,
ReferenceCode = referenceCode, ReferenceCode = verifyResult.RefId ?? string.Empty,
PurchaseInfo = isSuccessful ? new PackagePurchaseInfo PurchaseInfo = verifyResult.IsSuccess ? new PackagePurchaseInfo
{ {
PackageId = 1, PackageId = purchase.PackageId,
PackageName = "پکیج طلایی", PackageName = purchase.Package?.Title ?? string.Empty,
AmountPaid = 5600000, AmountPaid = purchase.Amount,
PurchaseDate = Timestamp.FromDateTime(DateTime.UtcNow), PurchaseDate = Timestamp.FromDateTime(DateTime.SpecifyKind(purchase.PurchasedAt, DateTimeKind.Utc))
ExpiryDate = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(365))
} : null } : 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) public override async Task<GetCustomerPurchaseHistoryResponse> GetCustomerPurchaseHistory(GetCustomerPurchaseHistoryRequest request, ServerCallContext context)
{ {
var query = new GetCustomerPurchaseHistoryQuery var query = new GetCustomerPurchaseHistoryQuery
@@ -1,65 +1,478 @@
using CMSMicroservice.Protobuf.Protos.Products; using CMSMicroservice.Protobuf.Protos.Products;
using Grpc.Core; using Grpc.Core;
using MediatR; 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.GetCustomerProducts;
using CMSMicroservice.Application.ProductsCQ.Queries.GetCustomerProductsByFilter; using CMSMicroservice.Application.ProductsCQ.Queries.GetCustomerProductsByFilter;
using CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery;
using CMSMicroservice.Application.Common.Interfaces;
using Mapster; using Mapster;
using Microsoft.EntityFrameworkCore;
using AppModels = CMSMicroservice.Application.Common.Models; using AppModels = CMSMicroservice.Application.Common.Models;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
public class ProductsService : ProductsContract.ProductsContractBase public class ProductsService : ProductsContract.ProductsContractBase
{ {
private readonly ISender _sender; private readonly ISender _sender;
private readonly IApplicationDbContext _context;
public ProductsService(ISender sender) public ProductsService(ISender sender, IApplicationDbContext context)
{ {
_sender = sender; _sender = sender;
_context = context;
} }
public override async Task<CreateNewProductsResponse> CreateNewProducts(CreateNewProductsRequest request, ServerCallContext 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) 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) 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) 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) public override async Task<GetAllProductsByFilterResponse> GetAllProductsByFilter(GetAllProductsByFilterRequest request, ServerCallContext context)
{ {
throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); // Map to GetCustomerProductsByFilter query (public products API)
var query = new GetCustomerProductsByFilterQuery
{
Id = request.Filter?.Id,
Title = request.Filter?.Title ?? string.Empty,
Description = request.Filter?.Description ?? string.Empty,
ShortInfomation = request.Filter?.ShortInfomation ?? string.Empty,
FullInformation = request.Filter?.FullInformation ?? string.Empty,
Price = request.Filter?.Price,
Discount = request.Filter?.Discount,
Rate = request.Filter?.Rate,
SaleCount = request.Filter?.SaleCount,
ViewCount = request.Filter?.ViewCount,
RemainingCount = request.Filter?.RemainingCount,
CategoryIds = request.Filter?.CategoryId.HasValue == true
? new List<long> { request.Filter.CategoryId.Value }
: new List<long>(),
SortBy = request.SortBy ?? string.Empty,
PaginationState = request.PaginationState != null
? new AppModels.PaginationState
{
PageNumber = request.PaginationState.PageNumber,
PageSize = request.PaginationState.PageSize
}
: new AppModels.PaginationState { PageNumber = 1, PageSize = 20 }
};
var result = await _sender.Send(query, context.CancellationToken);
return new GetAllProductsByFilterResponse
{
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{
CurrentPage = result.MetaData.CurrentPage,
TotalPage = result.MetaData.TotalPage,
PageSize = result.MetaData.PageSize,
TotalCount = result.MetaData.TotalCount,
HasPrevious = result.MetaData.HasPrevious,
HasNext = result.MetaData.HasNext
},
Models = { result.Models.Select(m => new GetAllProductsByFilterResponseModel
{
Id = m.Id,
Title = m.Title,
Description = m.Description,
ShortInfomation = m.ShortInfomation,
FullInformation = m.FullInformation,
Price = m.Price,
Discount = m.Discount,
Rate = m.Rate,
ImagePath = m.ImagePath,
ThumbnailPath = m.ThumbnailPath,
SaleCount = m.SaleCount,
ViewCount = m.ViewCount,
RemainingCount = m.RemainingCount,
CategoryIds = { m.Categories?.Select(c => c.CategoryId) ?? Enumerable.Empty<long>() }
}) }
};
} }
public override async Task<BulkUpdateProductPricesResponse> BulkUpdateProductPrices(BulkUpdateProductPricesRequest request, ServerCallContext context) 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) 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) 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) 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 ============= // ============= Customer-specific Methods =============
@@ -221,4 +634,56 @@ public class ProductsService : ProductsContract.ProductsContractBase
return response; 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.Commands.RefundTransaction;
using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransaction; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransaction;
using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransactionsByFilter; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransactionsByFilter;
using CMSMicroservice.Application.Common.Interfaces;
using AppModels = CMSMicroservice.Application.Common.Models; using AppModels = CMSMicroservice.Application.Common.Models;
using Grpc.Core;
using MediatR; using MediatR;
using Mapster; using Mapster;
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
public class TransactionsService : TransactionsContract.TransactionsContractBase public class TransactionsService : TransactionsContract.TransactionsContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly ISender _sender; 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; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender; _sender = sender;
_context = context;
_currentUserService = currentUserService;
_paymentGateway = paymentGateway;
} }
public override async Task<CreateNewTransactionsResponse> CreateNewTransactions(CreateNewTransactionsRequest request, ServerCallContext context) 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) 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 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) public override async Task<CustomerPaymentVerificationResponse> CustomerPaymentVerification(CustomerPaymentVerificationRequest request, ServerCallContext context)
{ {
// Mock payment verification response // Find the transaction by authority/refId
bool isSuccessful = request.Status == "OK"; var transaction = await _context.Transactions
.Where(t => t.RefId == request.Authority && !t.IsDeleted)
.FirstOrDefaultAsync(context.CancellationToken);
if (transaction == null)
throw new RpcException(new Status(StatusCode.NotFound, "تراکنش یافت نشد"));
// If status from gateway callback is not OK, mark as rejected
if (request.Status != "OK")
{
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
await _context.SaveChangesAsync(context.CancellationToken);
return new CustomerPaymentVerificationResponse
{
Id = transaction.Id,
PaymentStatus = false,
Message = "پرداخت توسط کاربر لغو شد",
VerificationStatusCode = -1
};
}
// Verify with gateway
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority, request.Status, context.CancellationToken);
if (verifyResult.IsSuccess)
{
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Success;
transaction.PaymentDate = DateTime.UtcNow;
transaction.RefId = verifyResult.RefId;
}
else
{
transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject;
}
await _context.SaveChangesAsync(context.CancellationToken);
return new CustomerPaymentVerificationResponse return new CustomerPaymentVerificationResponse
{ {
Id = 12345, Id = transaction.Id,
PaymentStatus = isSuccessful, PaymentStatus = verifyResult.IsSuccess,
Message = isSuccessful ? "پرداخت با موفقیت انجام شد" : "پرداخت ناموفق", Message = verifyResult.IsSuccess ? "پرداخت با موفقیت انجام شد" : (verifyResult.Message ?? "پرداخت ناموفق"),
RefId = isSuccessful ? "REF123456789" : null, RefId = verifyResult.RefId ?? string.Empty,
OrderId = "ORDER001", OrderId = string.Empty,
VerificationStatusCode = isSuccessful ? 101 : 102 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.RemoveFromCustomerCart;
using CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateCustomerCartItem; using CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateCustomerCartItem;
using CMSMicroservice.Application.DiscountShopCQ.Queries.GetCustomerCart; using CMSMicroservice.Application.DiscountShopCQ.Queries.GetCustomerCart;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Protobuf.Protos.UserCarts; using CMSMicroservice.Protobuf.Protos.UserCarts;
using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.WebApi.Common.Services;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using MediatR; using MediatR;
using System.Linq;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
@@ -13,11 +16,13 @@ public class UserCartsService : UserCartsContract.UserCartsContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatcher; private readonly IDispatchRequestToCQRS _dispatcher;
private readonly ISender _sender; private readonly ISender _sender;
private readonly IApplicationDbContext _context;
public UserCartsService(IDispatchRequestToCQRS dispatcher, ISender sender) public UserCartsService(IDispatchRequestToCQRS dispatcher, ISender sender, IApplicationDbContext context)
{ {
_dispatcher = dispatcher; _dispatcher = dispatcher;
_sender = sender; _sender = sender;
_context = context;
} }
#region Customer Methods #region Customer Methods
@@ -117,31 +122,116 @@ public class UserCartsService : UserCartsContract.UserCartsContractBase
public override async Task<AddNewUserCartResponse> AddNewUserCart( public override async Task<AddNewUserCartResponse> AddNewUserCart(
AddNewUserCartRequest request, ServerCallContext context) 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( public override async Task<Empty> UpdateUserCart(
UpdateUserCartRequest request, ServerCallContext context) 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( public override async Task<Empty> DeleteUserCart(
DeleteUserCartRequest request, ServerCallContext context) 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( public override async Task<GetUserCartResponse> GetUserCart(
GetUserCartRequest request, ServerCallContext context) 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( public override async Task<GetAllUserCartsByFilterResponse> GetAllUserCartsByFilter(
GetAllUserCartsByFilterRequest request, ServerCallContext context) 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 #endregion
@@ -1,78 +1,569 @@
using CMSMicroservice.Application.Common.Authorization;
using CMSMicroservice.Protobuf.Protos.UserOrder; using CMSMicroservice.Protobuf.Protos.UserOrder;
using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrders; using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrders;
using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrder; using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrder;
using CMSMicroservice.Application.UserOrderCQ.Queries.GetCustomerOrderHistory; 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;
using CMSMicroservice.Domain.Enums;
using AppModels = CMSMicroservice.Application.Common.Models; using AppModels = CMSMicroservice.Application.Common.Models;
using Grpc.Core; using Grpc.Core;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using CMSMicroservice.Protobuf.Protos; using CMSMicroservice.Protobuf.Protos;
using MediatR; using MediatR;
using Mapster; using Mapster;
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
public class UserOrderService : UserOrderContract.UserOrderContractBase public class UserOrderService : UserOrderContract.UserOrderContractBase
{ {
private readonly ISender _sender; private readonly ISender _sender;
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUserService;
public UserOrderService(ISender sender) public UserOrderService(ISender sender, IApplicationDbContext context, ICurrentUserService currentUserService)
{ {
_sender = sender; _sender = sender;
_context = context;
_currentUserService = currentUserService;
} }
[RequiresPermission(PermissionNames.OrdersCreate)]
public override async Task<CreateNewUserOrderResponse> CreateNewUserOrder(CreateNewUserOrderRequest request, ServerCallContext context) 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) 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) 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) public override async Task<GetUserOrderResponse> GetUserOrder(GetUserOrderRequest request, ServerCallContext context)
{ {
throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); var query = new GetCustomerOrderQuery
{
OrderId = request.Id,
UserId = 0 // از JWT دریافت می‌شود
};
var result = await _sender.Send(query, context.CancellationToken);
var response = new GetUserOrderResponse
{
Id = result.Id,
Amount = result.Amount,
PackageId = result.PackageId ?? 0,
TransactionId = result.TransactionId,
UserId = result.UserId,
UserAddressId = result.UserAddressId,
UserAddressText = result.UserAddressText,
TrackingCode = result.TrackingCode,
DeliveryDescription = result.DeliveryDescription,
UserFullName = result.UserFullName,
UserNationalCode = result.UserNationalCode
};
// VAT Info
if (result.VatAmount > 0)
{
response.VatInfo = new OrderVATInfo
{
VatRate = result.VatPercentage / 100,
BaseAmount = result.Amount - result.VatAmount,
VatAmount = result.VatAmount,
TotalAmount = result.Amount,
IsPaid = result.PaymentStatus == Domain.Enums.PaymentStatus.Success
};
}
response.PaymentStatus = (CMSMicroservice.Protobuf.Protos.PaymentStatus)result.PaymentStatus;
if (result.PaymentDate.HasValue)
response.PaymentDate = Timestamp.FromDateTime(DateTime.SpecifyKind(result.PaymentDate.Value, DateTimeKind.Utc));
if (result.PaymentMethod.HasValue)
response.PaymentMethod = (CMSMicroservice.Protobuf.Protos.PaymentMethod)result.PaymentMethod.Value;
response.DeliveryStatus = (CMSMicroservice.Protobuf.Protos.DeliveryStatus)result.DeliveryStatus;
foreach (var fd in result.FactorDetails)
{
response.FactorDetails.Add(new GetUserOrderResponseFactorDetail
{
ProductId = fd.ProductId,
ProductTitle = fd.ProductTitle,
ProductThumbnailPath = fd.ProductThumbnailPath,
UnitPrice = fd.UnitPrice,
Count = fd.Count,
UnitDiscountPrice = fd.UnitDiscountPrice
});
}
return response;
} }
[RequiresPermission(PermissionNames.OrdersView)]
public override async Task<GetAllUserOrderByFilterResponse> GetAllUserOrderByFilter(GetAllUserOrderByFilterRequest request, ServerCallContext context) public override async Task<GetAllUserOrderByFilterResponse> GetAllUserOrderByFilter(GetAllUserOrderByFilterRequest request, ServerCallContext context)
{ {
throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); // Admin API - can view all orders or filter by specific user
var query = new GetCustomerOrdersQuery
{
UserId = request.Filter?.UserId ?? 0, // 0 means all users (admin view)
PaginationState = request.PaginationState?.Adapt<AppModels.PaginationState>(),
PaymentStatusFilter = request.Filter?.PaymentStatus != null
? (int?)request.Filter.PaymentStatus
: null,
DeliveryStatusFilter = request.Filter?.DeliveryStatus != null
? (int?)request.Filter.DeliveryStatus
: null,
FromDate = request.Filter?.PaymentDate?.ToDateTime(),
ToDate = null
};
var result = await _sender.Send(query, context.CancellationToken);
var response = new GetAllUserOrderByFilterResponse
{
MetaData = result.MetaData.Adapt<MetaData>()
};
foreach (var model in result.Models)
{
var orderModel = new GetAllUserOrderByFilterResponseModel
{
Id = model.Id,
Amount = model.Amount,
PackageId = model.PackageId ?? 0,
TransactionId = model.TransactionId,
UserId = model.UserId,
UserAddressId = model.UserAddressId,
UserAddressText = model.UserAddressText,
TrackingCode = model.TrackingCode,
DeliveryDescription = model.DeliveryDescription,
UserFullName = model.UserFullName,
UserNationalCode = model.UserNationalCode,
VatAmount = model.VatAmount,
VatPercentage = model.VatPercentage
};
orderModel.PaymentStatus = (CMSMicroservice.Protobuf.Protos.PaymentStatus)model.PaymentStatus;
if (model.PaymentDate.HasValue)
orderModel.PaymentDate = Timestamp.FromDateTime(DateTime.SpecifyKind(model.PaymentDate.Value, DateTimeKind.Utc));
if (model.PaymentMethod.HasValue)
orderModel.PaymentMethod = (CMSMicroservice.Protobuf.Protos.PaymentMethod)model.PaymentMethod.Value;
orderModel.DeliveryStatus = (CMSMicroservice.Protobuf.Protos.DeliveryStatus)model.DeliveryStatus;
foreach (var fd in model.FactorDetails)
{
orderModel.FactorDetails.Add(new GetAllUserOrderByFilterResponseModelFactorDetail
{
ProductId = fd.ProductId,
ProductTitle = fd.ProductTitle,
ProductThumbnailPath = fd.ProductThumbnailPath,
UnitPrice = fd.UnitPrice,
Count = fd.Count,
UnitDiscountPrice = fd.UnitDiscountPrice
});
}
response.Models.Add(orderModel);
}
return response;
} }
public override async Task<SubmitShopBuyOrderResponse> SubmitShopBuyOrder(SubmitShopBuyOrderRequest request, ServerCallContext context) public override async Task<SubmitShopBuyOrderResponse> SubmitShopBuyOrder(SubmitShopBuyOrderRequest request, ServerCallContext context)
{ {
throw new RpcException(new Status(StatusCode.Unimplemented, "Method not implemented yet")); // Get UserId from JWT or request
var userId = !string.IsNullOrEmpty(_currentUserService.UserId)
? long.Parse(_currentUserService.UserId)
: request.UserId;
if (userId == 0)
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "User not authenticated"));
}
// Get user's cart items with product details
var cartItems = await _context.UserCarts
.Include(c => c.Product)
.Where(c => c.UserId == userId && !c.IsDeleted)
.ToListAsync(context.CancellationToken);
if (!cartItems.Any())
{
throw new RpcException(new Status(StatusCode.FailedPrecondition, "سبد خرید خالی است"));
}
// Get user's default address
var defaultAddress = await _context.UserAddresses
.Where(a => a.UserId == userId && a.IsDefault && !a.IsDeleted)
.FirstOrDefaultAsync(context.CancellationToken);
if (defaultAddress == null)
{
throw new RpcException(new Status(StatusCode.FailedPrecondition, "آدرس پیش‌فرض یافت نشد"));
}
// Calculate amounts
const decimal vatRate = 0.09m;
long baseAmount = cartItems.Sum(c => c.Product.Price * c.Count);
long vatAmount = (long)(baseAmount * vatRate);
long totalAmount = baseAmount + vatAmount;
// Validate total amount
if (request.TotalAmount > 0 && Math.Abs(totalAmount - request.TotalAmount) > 100)
{
throw new RpcException(new Status(StatusCode.InvalidArgument,
$"مبلغ نامعتبر است. محاسبه شده: {totalAmount}, دریافتی: {request.TotalAmount}"));
}
// Get user's wallet
var wallet = await _context.UserWallets
.Where(w => w.UserId == userId)
.FirstOrDefaultAsync(context.CancellationToken);
if (wallet == null)
{
throw new RpcException(new Status(StatusCode.FailedPrecondition, "کیف پول یافت نشد"));
}
// Check wallet balance
if (wallet.Balance < totalAmount)
{
throw new RpcException(new Status(StatusCode.FailedPrecondition,
$"موجودی کیف پول کافی نیست. موجودی: {wallet.Balance:N0} تومان، مورد نیاز: {totalAmount:N0} تومان"));
}
// Create transaction
var transaction = new Transaction
{
Amount = totalAmount,
Description = $"خرید محصولات - سفارش شماره در حال ایجاد",
PaymentStatus = CMSMicroservice.Domain.Enums.PaymentStatus.Success,
PaymentDate = DateTime.UtcNow,
Type = CMSMicroservice.Domain.Enums.TransactionType.Buy,
RefId = $"SHOP_{DateTime.UtcNow.Ticks}"
};
_context.Transactions.Add(transaction);
await _context.SaveChangesAsync(context.CancellationToken);
// Deduct from wallet
var oldBalance = wallet.Balance;
wallet.Balance -= totalAmount;
// Create wallet change log
var walletLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = -totalAmount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = 0,
IsIncrease = false,
RefrenceId = transaction.Id
};
_context.UserWalletChangeLogs.Add(walletLog);
// Create order
var order = new UserOrder
{
UserId = userId,
UserAddressId = defaultAddress.Id,
Amount = totalAmount,
TransactionId = transaction.Id,
PaymentStatus = CMSMicroservice.Domain.Enums.PaymentStatus.Success,
PaymentDate = DateTime.UtcNow,
PaymentMethod = CMSMicroservice.Domain.Enums.PaymentMethod.Wallet,
DeliveryStatus = CMSMicroservice.Domain.Enums.DeliveryStatus.Pending,
HasVAT = true
};
_context.UserOrders.Add(order);
await _context.SaveChangesAsync(context.CancellationToken);
// Update transaction description with order ID
transaction.Description = $"خرید محصولات - سفارش #{order.Id}";
await _context.SaveChangesAsync(context.CancellationToken);
// Create order VAT record
var orderVat = new OrderVAT
{
OrderId = order.Id,
VATRate = vatRate,
BaseAmount = baseAmount,
VATAmount = vatAmount,
TotalAmount = totalAmount
};
_context.OrderVATs.Add(orderVat);
// Create factor details for each cart item
foreach (var cartItem in cartItems)
{
var factorDetail = new FactorDetails
{
OrderId = order.Id,
ProductId = cartItem.ProductId,
Count = cartItem.Count,
UnitPrice = cartItem.Product.Price,
UnitDiscount = 0,
UnitDiscountPrice = cartItem.Product.Price,
IsChangePrice = false
};
_context.FactorDetails.Add(factorDetail);
}
await _context.SaveChangesAsync(context.CancellationToken);
// Clear user's cart
foreach (var cartItem in cartItems)
{
cartItem.IsDeleted = true;
}
await _context.SaveChangesAsync(context.CancellationToken);
return new SubmitShopBuyOrderResponse
{
Id = order.Id
};
} }
[RequiresPermission(PermissionNames.OrdersCancel)]
public override async Task<CancelOrderResponse> CancelOrder(CancelOrderRequest request, ServerCallContext context) 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) 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) 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) 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) 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 ============= // ============= Customer-specific Methods =============
@@ -131,14 +622,14 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
VatPercentage = model.VatPercentage VatPercentage = model.VatPercentage
}; };
orderModel.PaymentStatus = (PaymentStatus)model.PaymentStatus; orderModel.PaymentStatus = (CMSMicroservice.Protobuf.Protos.PaymentStatus)model.PaymentStatus;
if (model.PaymentDate.HasValue) if (model.PaymentDate.HasValue)
orderModel.PaymentDate = Timestamp.FromDateTime(DateTime.SpecifyKind(model.PaymentDate.Value, DateTimeKind.Utc)); orderModel.PaymentDate = Timestamp.FromDateTime(DateTime.SpecifyKind(model.PaymentDate.Value, DateTimeKind.Utc));
if (model.PaymentMethod.HasValue) if (model.PaymentMethod.HasValue)
orderModel.PaymentMethod = (PaymentMethod)model.PaymentMethod.Value; orderModel.PaymentMethod = (CMSMicroservice.Protobuf.Protos.PaymentMethod)model.PaymentMethod.Value;
orderModel.DeliveryStatus = (DeliveryStatus)model.DeliveryStatus; orderModel.DeliveryStatus = (CMSMicroservice.Protobuf.Protos.DeliveryStatus)model.DeliveryStatus;
foreach (var fd in model.FactorDetails) foreach (var fd in model.FactorDetails)
{ {
@@ -197,14 +688,14 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
}; };
} }
response.PaymentStatus = (PaymentStatus)result.PaymentStatus; response.PaymentStatus = (CMSMicroservice.Protobuf.Protos.PaymentStatus)result.PaymentStatus;
if (result.PaymentDate.HasValue) if (result.PaymentDate.HasValue)
response.PaymentDate = Timestamp.FromDateTime(DateTime.SpecifyKind(result.PaymentDate.Value, DateTimeKind.Utc)); response.PaymentDate = Timestamp.FromDateTime(DateTime.SpecifyKind(result.PaymentDate.Value, DateTimeKind.Utc));
if (result.PaymentMethod.HasValue) if (result.PaymentMethod.HasValue)
response.PaymentMethod = (PaymentMethod)result.PaymentMethod.Value; response.PaymentMethod = (CMSMicroservice.Protobuf.Protos.PaymentMethod)result.PaymentMethod.Value;
response.DeliveryStatus = (DeliveryStatus)result.DeliveryStatus; response.DeliveryStatus = (CMSMicroservice.Protobuf.Protos.DeliveryStatus)result.DeliveryStatus;
foreach (var fd in result.FactorDetails) foreach (var fd in result.FactorDetails)
{ {
@@ -226,13 +717,53 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
public override async Task<CustomerCancelOrderResponse> CustomerCancelOrder(CustomerCancelOrderRequest request, ServerCallContext context) 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 return new CustomerCancelOrderResponse
{ {
Success = true, Success = true,
Message = "سفارش شما با موفقیت لغو شد", Message = "سفارش شما با موفقیت لغو شد",
RefundAmount = 180000, RefundAmount = refundAmount,
RefundTransactionId = "REF" + DateTimeOffset.UtcNow.ToUnixTimeSeconds() RefundTransactionId = $"REF{order.Id}"
}; };
} }
@@ -282,106 +813,130 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
public override async Task<CustomerTrackOrderResponse> CustomerTrackOrder(CustomerTrackOrderRequest request, ServerCallContext context) public override async Task<CustomerTrackOrderResponse> CustomerTrackOrder(CustomerTrackOrderRequest request, ServerCallContext context)
{ {
// Mock Customer order tracking with detailed Persian information var order = await _context.UserOrders
var statusHistory = new List<OrderStatusHistory> .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 Id = order.Id,
{ Amount = order.Amount,
Status = OrderStatusEnum.OrderStatusPending, PackageId = order.PackageId ?? 0,
StatusMessage = "در انتظار تایید", PackageName = order.Package?.Title ?? string.Empty,
ChangedAt = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-5)), Status = (OrderStatusEnum)(int)order.DeliveryStatus,
ChangedBy = "سیستم" StatusMessage = GetDeliveryStatusPersian(order.DeliveryStatus),
}, OrderDate = Timestamp.FromDateTime(DateTime.SpecifyKind(order.Created, DateTimeKind.Utc)),
new OrderStatusHistory TrackingCode = order.TrackingCode ?? string.Empty,
{ ItemsCount = order.FactorDetails.Count(f => !f.IsDeleted),
Status = OrderStatusEnum.OrderStatusConfirmed, CanCancel = order.DeliveryStatus == Domain.Enums.DeliveryStatus.Pending,
StatusMessage = "تایید شده", CanReorder = order.DeliveryStatus == Domain.Enums.DeliveryStatus.Delivered
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 = "پست پیشتاز"
}
}; };
var deliverySteps = new List<DeliveryStep> var response = new CustomerTrackOrderResponse
{ {
new DeliveryStep Order = orderModel,
{
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 },
DeliveryInfo = new DeliveryTrackingInfo DeliveryInfo = new DeliveryTrackingInfo
{ {
TrackingCode = "TRK" + request.OrderId.ToString("000"), TrackingCode = order.TrackingCode ?? string.Empty,
CourierName = "پست پیشتاز", CourierName = "پست پیشتاز",
EstimatedDelivery = "فردا تا ساعت 18:00", EstimatedDelivery = order.DeliveryDescription ?? string.Empty,
CurrentLocation = "مرکز پخش منطقه 5 تهران", CurrentLocation = string.Empty
DeliverySteps = { deliverySteps }
} }
}; };
// 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) public override async Task<CustomerReorderResponse> CustomerReorderPreviousOrder(CustomerReorderRequest request, ServerCallContext context)
{ {
// Mock Customer reorder functionality var originalOrder = await _context.UserOrders
var newOrderId = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); .Include(o => o.FactorDetails)
var totalAmount = request.UseCurrentPrices ? 280000 : 250000; .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 return new CustomerReorderResponse
{ {
Success = true, Success = true,
Message = request.UseCurrentPrices ? Message = "محصولات به سبد خرید اضافه شدند",
"سفارش مجدد با قیمت‌های جدید ثبت شد" : NewOrderId = 0, // Cart items added, no order created yet
"سفارش مجدد با قیمت‌های قبلی ثبت شد",
NewOrderId = newOrderId,
TotalAmount = totalAmount TotalAmount = totalAmount
}; };
} }
public override Task<GetVATRateResponse> GetVATRate(Google.Protobuf.WellKnownTypes.Empty request, ServerCallContext context)
{
// VAT Rate for Iran: 9% (نرخ مالیات بر ارزش افزوده ایران)
return Task.FromResult(new GetVATRateResponse
{
VatRate = 0.09,
VatPercentage = 9,
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.GetCustomerProfile;
using CMSMicroservice.Application.UserCQ.Queries.GetCustomerReferrals; using CMSMicroservice.Application.UserCQ.Queries.GetCustomerReferrals;
using CMSMicroservice.Application.UserCQ.Queries.GetCustomerSettings; using CMSMicroservice.Application.UserCQ.Queries.GetCustomerSettings;
using CMSMicroservice.Application.Common.Interfaces;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using Grpc.Core;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using MediatR; using MediatR;
@@ -28,11 +31,25 @@ public class UserService : UserContract.UserContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly ISender _sender; 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; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender; _sender = sender;
_context = context;
_currentUserService = currentUserService;
_hashService = hashService;
_fileManagementService = fileManagementService;
} }
public override async Task<CreateNewUserResponse> CreateNewUser(CreateNewUserRequest request, ServerCallContext context) 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) public override async Task<GetUserForCustomerResponse> GetUserForCustomer(GetUserForCustomerRequest request, ServerCallContext context)
{ {
// Mock implementation for Customer Get User var userId = GetCurrentUserId();
await Task.Delay(10); // Simulate async operation
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 return new GetUserForCustomerResponse
{ {
Id = 123, Id = user.Id,
FirstName = "احمد", FirstName = user.FirstName ?? string.Empty,
LastName = "محمدی", LastName = user.LastName ?? string.Empty,
Mobile = "09123456789", Mobile = user.Mobile,
Email = "ahmad.mohammadi@example.com", Email = user.Email ?? string.Empty,
NationalCode = "1234567890", NationalCode = user.NationalCode ?? string.Empty,
AvatarPath = "/avatars/user_123.jpg", AvatarPath = user.AvatarPath ?? string.Empty,
ParentId = 100, ParentId = user.NetworkParentId,
ReferralCode = "REF123456", ReferralCode = user.ReferralCode ?? string.Empty,
IsMobileVerified = true, IsMobileVerified = user.IsMobileVerified,
MobileVerifiedAt = Timestamp.FromDateTime(DateTime.SpecifyKind(new DateTime(2024, 1, 15), DateTimeKind.Utc)), MobileVerifiedAt = user.MobileVerifiedAt.HasValue
EmailNotifications = true, ? Timestamp.FromDateTime(DateTime.SpecifyKind(user.MobileVerifiedAt.Value, DateTimeKind.Utc))
SmsNotifications = true, : null,
PushNotifications = false, EmailNotifications = user.EmailNotifications,
BirthDate = Timestamp.FromDateTime(DateTime.SpecifyKind(new DateTime(1990, 5, 20), DateTimeKind.Utc)) 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) public override async Task<Empty> UpdateCustomerProfile(UpdateCustomerProfileRequest request, ServerCallContext context)
{ {
// Mock implementation for Update Customer Profile var userId = GetCurrentUserId();
await Task.Delay(10);
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(); return new Empty();
} }
@@ -153,9 +195,6 @@ public class UserService : UserContract.UserContractBase
public override async Task<ChangeCustomerPasswordResponse> ChangeCustomerPassword(ChangeCustomerPasswordRequest request, ServerCallContext context) 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) if (request.NewPassword != request.ConfirmPassword)
{ {
return new ChangeCustomerPasswordResponse 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 return new ChangeCustomerPasswordResponse
{ {
Success = true, Success = true,
@@ -233,9 +298,6 @@ public class UserService : UserContract.UserContractBase
public override async Task<UploadCustomerAvatarResponse> UploadCustomerAvatar(UploadCustomerAvatarRequest request, ServerCallContext context) 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) if (request.FileData == null || request.FileData.Length == 0)
{ {
return new UploadCustomerAvatarResponse return new UploadCustomerAvatarResponse
@@ -264,9 +326,35 @@ public class UserService : UserContract.UserContractBase
}; };
} }
// Simulate file upload and generate URL var userId = GetCurrentUserId();
var fileName = $"avatar_{DateTime.Now.Ticks}.{request.FileMimeType?.Split('/').LastOrDefault()}";
var avatarUrl = $"/uploads/avatars/{fileName}"; 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 return new UploadCustomerAvatarResponse
{ {
@@ -295,8 +383,32 @@ public class UserService : UserContract.UserContractBase
public override async Task<Empty> UpdateCustomerSettings(UpdateCustomerSettingsRequest request, ServerCallContext context) public override async Task<Empty> UpdateCustomerSettings(UpdateCustomerSettingsRequest request, ServerCallContext context)
{ {
// Mock implementation for Update Customer Settings var userId = GetCurrentUserId();
await Task.Delay(10);
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(); 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.GetCustomerWalletChangeLog;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings;
using CMSMicroservice.Application.Common.Interfaces;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
public class UserWalletService : UserWalletContract.UserWalletContractBase public class UserWalletService : UserWalletContract.UserWalletContractBase
{ {
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
private readonly ISender _sender; 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; _dispatchRequestToCQRS = dispatchRequestToCQRS;
_sender = sender; _sender = sender;
_context = context;
_currentUserService = currentUserService;
} }
public override async Task<CreateNewUserWalletResponse> CreateNewUserWallet(CreateNewUserWalletRequest request, ServerCallContext context) public override async Task<CreateNewUserWalletResponse> CreateNewUserWallet(CreateNewUserWalletRequest request, ServerCallContext context)
{ {
@@ -87,6 +100,8 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
ChangeValue = log.ChangeValue, ChangeValue = log.ChangeValue,
CurrentNetworkBalance = log.CurrentNetworkBalance, CurrentNetworkBalance = log.CurrentNetworkBalance,
ChangeNerworkValue = log.ChangeNerworkValue, ChangeNerworkValue = log.ChangeNerworkValue,
CurrentDiscountBalance = log.CurrentDiscountBalance,
ChangeDiscountValue = log.ChangeDiscountValue,
IsIncrease = log.IsIncrease, IsIncrease = log.IsIncrease,
RefrenceId = log.RefrenceId, RefrenceId = log.RefrenceId,
CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(DateTime.SpecifyKind(log.Created, DateTimeKind.Utc)) CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(DateTime.SpecifyKind(log.Created, DateTimeKind.Utc))
@@ -98,10 +113,38 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
public override async Task<Google.Protobuf.WellKnownTypes.Empty> CustomerWithdrawBalance(CustomerWithdrawBalanceRequest request, ServerCallContext context) 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(); 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) public override async Task<GetCustomerWithdrawalsResponse> GetCustomerWithdrawals(GetCustomerWithdrawalsRequest request, ServerCallContext context)
{ {
var query = new GetCustomerWithdrawalsQuery var query = new GetCustomerWithdrawalsQuery
@@ -1,5 +1,8 @@
{ {
"UseRealPaymentGateway": false, "UseRealPaymentGateway": false,
"FMS": {
"Address": "https://dl.afrino.co"
},
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=", "JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost", "JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost", "JwtAudience": "https://localhost",