Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b0d9eadd3 | |||
| db07838ecb | |||
| 167991890c | |||
| ef1716e243 | |||
| c2ccd32f39 | |||
| c435319025 | |||
| 18a65de8c7 | |||
| e2e09ac4e8 | |||
| 61a0d68b7c | |||
| 1c4beb5f05 | |||
| 163a0a2f1a | |||
| e77611012b | |||
| a39a36e66d | |||
| 207f53ef80 | |||
| 2502cbbda2 | |||
| 5a4e4a960d | |||
| dc1e00f514 | |||
| 9d929dc7fb | |||
| 9b7d61593b | |||
| e3e3e8cec4 | |||
| 2885ff20c0 | |||
| f2a0b4d211 | |||
| 6c9f3dd671 | |||
| d1ca72300d | |||
| b42d9e141d | |||
| f64b6be7da | |||
| 9185aa227d | |||
| b2d676b555 | |||
| b41342dcad | |||
| c3eeb16856 | |||
| 794dd01ac0 | |||
| 658d076bdf | |||
| 1a425f0d93 | |||
| 96daf899c7 | |||
| eddc82dfce | |||
| 0c95b03f0b | |||
| 45c01fc157 | |||
| 55a775f51c | |||
| 5b0558ee24 | |||
| cabf3886de | |||
| 0baed05fd4 | |||
| 7c4c978d3f | |||
| e327eb23ba | |||
| 20280cf07f | |||
| c67caca0ae | |||
| e14a653fa8 | |||
| 4968878773 | |||
| 8ffb53996b | |||
| 1a01fd9057 | |||
| 52af1bfaad | |||
| 2fd5300c1e | |||
| c76af47a4c | |||
| 77be841221 | |||
| 4743b52cae | |||
| 0b0fa3aa61 | |||
| 839250e132 | |||
| 8894e72a8e | |||
| 1c130b14e1 | |||
| 793d81f1dd | |||
| f6704eaa99 | |||
| d03fbe0a98 | |||
| f9c90bb871 | |||
| 70c4b15a23 | |||
| 5e662b5984 | |||
| 16ba2fa6fb | |||
| 389568af2b | |||
| 98f3036bd0 | |||
| 631646776e | |||
| d8031a0d17 | |||
| ca9b02a902 | |||
| dde4c68b2f | |||
| 8c3d710253 | |||
| a81fb39a32 | |||
| f8731bc7fc | |||
| d3d021c007 | |||
| bea43a0569 | |||
| 1cf501711f | |||
| f0117eb1d5 | |||
| 6f8aefc2ce | |||
| 500e169141 | |||
| ed45a1759b | |||
| 2bd5c7db78 | |||
| 7087df23ef | |||
| 6374d5c343 | |||
| 1dbb3ee502 | |||
| 0728ec2b76 | |||
| 1425fb187b | |||
| cd2549775a | |||
| 81d2b39ee1 | |||
| fce194a195 | |||
| 322242fbab | |||
| 3d42c91d21 | |||
| 9448f3fff0 | |||
| 02e2f8111f | |||
| 9d2b5ad2d4 | |||
| 8940362575 | |||
| bca3b7fe62 | |||
| c03b3705c3 | |||
| a9140bec04 | |||
| 17c2958b9c | |||
| 2e54aa7aca | |||
| 7b26073c63 | |||
| 28e94d6137 | |||
| c6ee356cbd | |||
| 2d09a69be9 | |||
| 7cebb27171 | |||
| 68489a8374 | |||
| 330f0cae5a | |||
| b16f01b6e4 | |||
| 13f24d523b | |||
| 41308e189a | |||
| 8953d86755 | |||
| ef8d5d4d97 | |||
| 288b85a59b | |||
| 30144a07c8 | |||
| 5bcf6c9840 | |||
| 9d51e70a7f | |||
| a31c88219f | |||
| ceeb29d843 | |||
| 0ba6af9049 | |||
| 5ad69cb3fa |
@@ -1,46 +0,0 @@
|
||||
|
||||
name: Push nuget and docker image Actions Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- stage_new
|
||||
jobs:
|
||||
Deploy:
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.afrino.co/actions/checkout@v3
|
||||
- name: Setup dotnet
|
||||
uses: https://git.afrino.co/actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Remove Package Source
|
||||
run: dotnet nuget remove source FourSat
|
||||
continue-on-error: true
|
||||
- name: Add Package Source
|
||||
run: dotnet nuget add source --name FourSat --username systemuser --password sZSA7PTiv3pUSQZ https://git.afrino.co/api/packages/FourSat/nuget/index.json --store-password-in-clear-text
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj"
|
||||
- name: Build
|
||||
run: dotnet build ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" --configuration Release --no-restore
|
||||
- name: Test
|
||||
run: dotnet test ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" --no-restore --verbosity normal
|
||||
- name: Recycle Apppool
|
||||
run: |
|
||||
& "C:\Windows\System32\inetsrv\appcmd.exe" recycle apppool /apppool.name:cms.kbs1.ir
|
||||
shell: powershell
|
||||
- name: Stop Website
|
||||
run: |
|
||||
& "C:\Windows\System32\inetsrv\appcmd.exe" stop site /site.name:cms.kbs1.ir
|
||||
shell: powershell
|
||||
- name: Publish
|
||||
run: dotnet publish ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" -c Release -o publish
|
||||
- name: Copy Publish To IIS Directory
|
||||
run: Get-ChildItem -Path "publish\*" | Copy-Item -Destination "E:\kbs1.ir\cms.kbs1.ir\" -Recurse -Force
|
||||
- name: Start Website
|
||||
run: |
|
||||
& "C:\Windows\System32\inetsrv\appcmd.exe" start site /site.name:cms.kbs1.ir
|
||||
shell: powershell
|
||||
@@ -1,86 +0,0 @@
|
||||
name: Build and Deploy to Kubernetes
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- kub-stage
|
||||
|
||||
env:
|
||||
REGISTRY: git.foursat.afrino.co
|
||||
IMAGE_NAME: admin/cms
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:latest
|
||||
options: --privileged
|
||||
env:
|
||||
HTTP_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
|
||||
HTTPS_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
|
||||
NO_PROXY: localhost,127.0.0.1,gitea-svc,194.5.195.53,10.0.0.0/8
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apk add --no-cache git curl
|
||||
|
||||
# Install kubectl
|
||||
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/
|
||||
|
||||
- name: Start Docker daemon with insecure registry
|
||||
run: |
|
||||
mkdir -p /etc/docker
|
||||
cat > /etc/docker/daemon.json << 'DAEMON'
|
||||
{
|
||||
"insecure-registries": ["git.foursat.afrino.co", "gitea-svc:3000"]
|
||||
}
|
||||
DAEMON
|
||||
mkdir -p ~/.docker
|
||||
cat > ~/.docker/config.json << 'CONF'
|
||||
{
|
||||
"proxies": {
|
||||
"default": {
|
||||
"httpProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
|
||||
"httpsProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
|
||||
"noProxy": "localhost,127.0.0.1,gitea-svc,194.5.195.53,10.0.0.0/8"
|
||||
}
|
||||
}
|
||||
}
|
||||
CONF
|
||||
dockerd &
|
||||
for i in $(seq 1 30); do
|
||||
docker info >/dev/null 2>&1 && break || sleep 2
|
||||
done
|
||||
docker info
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/CMS.git .
|
||||
git log -1 --format="%H %s"
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
--build-arg HTTP_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
|
||||
--build-arg HTTPS_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
|
||||
.
|
||||
|
||||
- name: Push to Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
run: |
|
||||
# Setup kubeconfig
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
|
||||
|
||||
# Restart deployment to pull new image
|
||||
kubectl rollout restart deployment/cms || echo "Deployment doesn't exist yet"
|
||||
|
||||
# Wait for rollout to complete
|
||||
kubectl rollout status deployment/cms --timeout=5m || echo "Deployment rollout pending"
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Build and Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
|
||||
env:
|
||||
REGISTRY: 194.5.195.53:30080
|
||||
IMAGE_NAME: admin/cms
|
||||
K8S_SERVER: 45.149.79.127
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 194.5.195.53:32082/docker-sshpass:latest
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: Start Docker daemon
|
||||
run: |
|
||||
mkdir -p /etc/docker
|
||||
cat > /etc/docker/daemon.json << 'DAEMON'
|
||||
{
|
||||
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
|
||||
}
|
||||
DAEMON
|
||||
echo "🚀 Starting Docker daemon..."
|
||||
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
|
||||
|
||||
for i in $(seq 1 90); do
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo "✅ Docker daemon is ready (attempt $i)"
|
||||
docker version
|
||||
break
|
||||
else
|
||||
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "❌ Docker daemon failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
|
||||
|
||||
- name: Login to Docker registries
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
||||
|
||||
- name: Publish Protobuf packages
|
||||
run: |
|
||||
echo "📦 Publishing Protobuf packages..."
|
||||
docker run --rm --network host -v $(pwd):/src -w /src \
|
||||
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
|
||||
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
|
||||
echo "📦 $proj"
|
||||
dotnet restore "$proj" --configfile src/NuGet.config
|
||||
dotnet build "$proj" -c Release --no-restore
|
||||
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
|
||||
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
|
||||
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
|
||||
--source "http://194.5.195.53:32081/repository/foursat-nuget-hosted/index.json" \
|
||||
--api-key "admin:87zH26nbqT" \
|
||||
--skip-duplicate --allow-insecure-connections || true
|
||||
done
|
||||
done
|
||||
'
|
||||
echo "✅ Protobuf packages done!"
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
DOCKER_BUILDKIT=0 docker build --network host \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
|
||||
.
|
||||
|
||||
- name: Push to Registry
|
||||
run: |
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
|
||||
|
||||
- name: Deploy to Production
|
||||
run: |
|
||||
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
||||
kubectl set image deployment/cms cms=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
kubectl rollout status deployment/cms --timeout=300s
|
||||
"
|
||||
echo "✅ Deployed to Production!"
|
||||
@@ -492,3 +492,8 @@ fabric.properties
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
/src/.idea
|
||||
|
||||
# Environment-specific configuration files with sensitive data
|
||||
**/appsettings.Staging.json
|
||||
**/appsettings.Production.json
|
||||
**/appsettings.*.local.json
|
||||
|
||||
+7
-4
@@ -1,14 +1,17 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM 194.5.195.53:32082/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy NuGet config first
|
||||
COPY src/NuGet.config ./
|
||||
|
||||
# Copy solution and project files
|
||||
COPY src/ ./
|
||||
|
||||
# Restore and publish
|
||||
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj"
|
||||
# Restore with Nexus config and publish
|
||||
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" --configfile NuGet.config
|
||||
RUN dotnet publish "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" -c Release -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||
FROM 194.5.195.53:32082/dotnet/aspnet:9.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
|
||||
@@ -1,38 +1,92 @@
|
||||
# CMS Microservice - Network & Club Commission System
|
||||
# CMS Microservice - Network & Club Commission + Inventory Management System
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
## 📊 Project Status (2025-12-01)
|
||||
## 📊 Project Status (January 2026)
|
||||
|
||||
**Overall Progress**: 85% Complete (7/10 phases)
|
||||
**Production Readiness**: 95%
|
||||
### 🏪 Inventory Management System - NEW!
|
||||
**Progress**: Phase 2 Complete (50%)
|
||||
**Architecture**: Clean Architecture + CQRS + Repository Pattern
|
||||
|
||||
#### ✅ Completed Phases
|
||||
1. ✅ **Phase 1: Infrastructure & Domain Layer**
|
||||
- Domain Entities: `InventoryItem`, `StockMovement`, `Warehouse`
|
||||
- Domain Enums: `StockMovementType`
|
||||
- EF Core Configurations with proper indexing
|
||||
- Database migration applied
|
||||
|
||||
2. ✅ **Phase 2: Repository Pattern & CQRS**
|
||||
- Repository Interfaces & Implementations
|
||||
- CQRS Commands (17 commands)
|
||||
- CQRS Queries (35 queries)
|
||||
- MediatR Handlers (52 handlers)
|
||||
|
||||
#### 🔄 In Progress
|
||||
3. 🔄 **Phase 3: Business Services Layer**
|
||||
4. ⏳ **Phase 4: DTOs & AutoMapper**
|
||||
5. ⏳ **Phase 5: API Controllers**
|
||||
|
||||
---
|
||||
|
||||
### 💼 Commission System - Production Ready
|
||||
**Progress**: 85% Complete
|
||||
**MVP Status**: ✅ 100% Complete
|
||||
|
||||
### ✅ Completed Phases (7)
|
||||
1. ✅ Domain Layer (Entities, Enums, Value Objects)
|
||||
2. ✅ Club Membership System
|
||||
3. ✅ Binary Network Tree
|
||||
4. ✅ **Commission Calculation & Background Worker** (MVP)
|
||||
5. ✅ Protobuf gRPC Services
|
||||
6. ✅ History & Configuration Management
|
||||
7. ✅ Database Migration & Seed Data
|
||||
#### ✅ Completed Features
|
||||
- ✅ Binary network tree with automatic placement
|
||||
- ✅ Club membership (Member/Trial) with commission rates
|
||||
- ✅ Weekly commission calculation (Lesser Leg algorithm)
|
||||
- ✅ Background worker with Hangfire
|
||||
- ✅ Email + SMS notifications (MailKit + Kavenegar)
|
||||
- ✅ Health check endpoints (Kubernetes-ready)
|
||||
|
||||
### 🟡 Partially Complete (1)
|
||||
### 🟡 Partially Complete
|
||||
- Phase 10: Withdrawal & Settlement (40%)
|
||||
- ✅ Commands & Database
|
||||
- ❌ Payment Gateway Integration
|
||||
|
||||
### ❌ Not Started (1)
|
||||
### ❌ Not Started
|
||||
- Phase 9: Club Shop & Product Integration (0%)
|
||||
|
||||
### ⏸️ Postponed (1)
|
||||
- Phase 7: Testing (Unit, Integration, Load tests)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Recent Updates (2025-12-01)
|
||||
## 🚀 Recent Updates (January 2026)
|
||||
|
||||
### 🏪 Inventory Management System - NEW! ✅
|
||||
**Complete CQRS-based inventory management with:**
|
||||
|
||||
#### Domain Layer:
|
||||
- ✅ `InventoryItem` - Multi-warehouse product tracking with min/max thresholds
|
||||
- ✅ `StockMovement` - Complete audit trail with 8 movement types
|
||||
- ✅ `Warehouse` - Multi-location support with default warehouse
|
||||
|
||||
#### Repository Pattern:
|
||||
- ✅ `IInventoryItemRepository` - 25+ methods for inventory operations
|
||||
- ✅ `IStockMovementRepository` - Movement tracking & analytics
|
||||
- ✅ `IWarehouseRepository` - Warehouse management & statistics
|
||||
|
||||
#### CQRS Commands (17 total):
|
||||
- **Inventory:** Create, Update, Delete, Reserve, Release, Reduce, Increase
|
||||
- **Movement:** Create, BulkCreate, Delete
|
||||
- **Warehouse:** Create, Update, Delete, SetDefault, Activate, BulkCreate
|
||||
|
||||
#### CQRS Queries (35 total):
|
||||
- **Inventory:** GetById, Search, LowStock, OutOfStock, CheckAvailability
|
||||
- **Movement:** GetHistory, GetByOrder, Search, Analytics, DailyVolume, TopMoving
|
||||
- **Warehouse:** GetById, Search, GetStats, GetLowStock, GetAllStats
|
||||
|
||||
#### Business Features:
|
||||
- ✅ Multi-warehouse inventory management
|
||||
- ✅ Stock reservation system for orders
|
||||
- ✅ Automatic movement tracking
|
||||
- ✅ Low stock & out-of-stock alerts
|
||||
- ✅ Advanced analytics & reporting
|
||||
- ✅ Bulk operations support
|
||||
- ✅ Transaction-safe operations
|
||||
|
||||
---
|
||||
|
||||
### Email & SMS Notifications - COMPLETED ✅
|
||||
- ✅ **MailKit 4.14.1** for Email (SMTP with HTML templates)
|
||||
@@ -63,8 +117,38 @@
|
||||
**Clean Architecture** with 4 layers:
|
||||
```
|
||||
CMSMicroservice.Domain/ # Entities, Enums, Interfaces
|
||||
├── Entities/
|
||||
│ ├── InventoryItem.cs # NEW: Inventory tracking
|
||||
│ ├── StockMovement.cs # NEW: Movement audit
|
||||
│ └── Warehouse.cs # NEW: Multi-warehouse
|
||||
├── Enums/
|
||||
│ └── StockMovementType.cs # NEW: Movement types
|
||||
|
||||
CMSMicroservice.Application/ # CQRS (Commands, Queries, MediatR)
|
||||
├── Features/
|
||||
│ ├── InventoryItems/ # NEW: Inventory CQRS
|
||||
│ │ ├── Commands/
|
||||
│ │ ├── Queries/
|
||||
│ │ └── Handlers/
|
||||
│ ├── StockMovements/ # NEW: Movement CQRS
|
||||
│ │ ├── Commands/
|
||||
│ │ ├── Queries/
|
||||
│ │ └── Handlers/
|
||||
│ └── Warehouses/ # NEW: Warehouse CQRS
|
||||
│ ├── Commands/
|
||||
│ ├── Queries/
|
||||
│ └── Handlers/
|
||||
└── Common/Interfaces/
|
||||
└── Repositories/ # NEW: Repository interfaces
|
||||
|
||||
CMSMicroservice.Infrastructure/ # DbContext, Services, Background Jobs
|
||||
├── Persistence/
|
||||
│ ├── Context/
|
||||
│ ├── Configurations/ # NEW: EF Core configs
|
||||
│ ├── Repositories/ # NEW: Repository implementations
|
||||
│ └── Migrations/
|
||||
└── DependencyInjection.cs # NEW: DI setup
|
||||
|
||||
CMSMicroservice.WebApi/ # gRPC Services, Controllers
|
||||
CMSMicroservice.Protobuf/ # Protocol Buffers definitions
|
||||
```
|
||||
@@ -84,6 +168,7 @@ CMSMicroservice.Protobuf/ # Protocol Buffers definitions
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **[Development Plan](docs/development-plan.md)** - NEW: Inventory system roadmap
|
||||
- **[Implementation Progress](docs/implementation-progress.md)** - Detailed phase-by-phase progress
|
||||
- **[Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)** - Production setup instructions
|
||||
- **[Balance Calculation Logic](docs/balance-calculation-carryover-logic.md)** - Commission algorithm details
|
||||
@@ -92,6 +177,74 @@ CMSMicroservice.Protobuf/ # Protocol Buffers definitions
|
||||
|
||||
---
|
||||
|
||||
## 🏪 Inventory System Usage
|
||||
|
||||
### Create Warehouse
|
||||
```csharp
|
||||
await mediator.Send(new CreateWarehouseCommand
|
||||
{
|
||||
Name = "Main Warehouse",
|
||||
Code = "WH-001",
|
||||
IsDefault = true,
|
||||
IsActive = true
|
||||
});
|
||||
```
|
||||
|
||||
### Create Inventory Item
|
||||
```csharp
|
||||
await mediator.Send(new CreateInventoryItemCommand
|
||||
{
|
||||
ProductId = 1,
|
||||
WarehouseId = 1,
|
||||
Quantity = 100,
|
||||
MinQuantity = 10,
|
||||
MaxQuantity = 1000
|
||||
});
|
||||
```
|
||||
|
||||
### Reserve Stock for Order
|
||||
```csharp
|
||||
await mediator.Send(new ReserveInventoryCommand
|
||||
{
|
||||
Id = inventoryId,
|
||||
Quantity = 5,
|
||||
OrderId = 12345
|
||||
});
|
||||
```
|
||||
|
||||
### Check Availability
|
||||
```csharp
|
||||
bool available = await mediator.Send(
|
||||
new CheckInventoryAvailabilityQuery(inventoryId, 10));
|
||||
```
|
||||
|
||||
### Get Low Stock Alerts
|
||||
```csharp
|
||||
var lowStock = await mediator.Send(new GetLowStockItemsQuery
|
||||
{
|
||||
WarehouseId = 1,
|
||||
Count = 50
|
||||
});
|
||||
```
|
||||
|
||||
### Get Movement Analytics
|
||||
```csharp
|
||||
var summary = await mediator.Send(new GetMovementSummaryQuery
|
||||
{
|
||||
FromDate = DateTime.Now.AddDays(-7),
|
||||
ToDate = DateTime.Now
|
||||
});
|
||||
|
||||
var topProducts = await mediator.Send(new GetTopMovingProductsQuery
|
||||
{
|
||||
FromDate = DateTime.Now.AddDays(-30),
|
||||
ToDate = DateTime.Now,
|
||||
Count = 10
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
@@ -197,7 +350,25 @@ curl http://localhost:5133/health/live # Liveness probe (K8s)
|
||||
|
||||
## 📊 What's Remaining?
|
||||
|
||||
### High Priority
|
||||
### 🏪 Inventory System (Current Focus)
|
||||
1. **Phase 3: Business Services** (In Progress)
|
||||
- `IInventoryManagementService` - High-level operations
|
||||
- `IStockMovementService` - Movement orchestration
|
||||
- `IWarehouseService` - Warehouse business logic
|
||||
- `IInventoryReportingService` - Advanced reporting
|
||||
|
||||
2. **Phase 4: DTOs & AutoMapper** (Next)
|
||||
- Request/Response DTOs
|
||||
- AutoMapper profiles
|
||||
- Validation rules
|
||||
|
||||
3. **Phase 5: API Controllers** (Planned)
|
||||
- `InventoryController` - REST API
|
||||
- `WarehouseController` - Warehouse management
|
||||
- `StockMovementController` - Movement tracking
|
||||
- Swagger documentation
|
||||
|
||||
### 💼 Commission System
|
||||
1. **Payment Gateway Integration** (Phase 10 - 1 week)
|
||||
- Daya or Bank Mellat API integration
|
||||
- IBAN transfer automation
|
||||
@@ -230,6 +401,7 @@ curl http://localhost:5133/health/live # Liveness probe (K8s)
|
||||
|
||||
## 🎯 MVP Features (100% Complete)
|
||||
|
||||
### 💼 Commission System:
|
||||
✅ Binary network tree with automatic placement
|
||||
✅ Club membership (Member/Trial) with different commission rates
|
||||
✅ Weekly commission calculation (Lesser Leg algorithm)
|
||||
@@ -244,12 +416,26 @@ curl http://localhost:5133/health/live # Liveness probe (K8s)
|
||||
✅ Structured logging (AlertService for Sentry/Slack)
|
||||
✅ JWT authentication context (CurrentUserService)
|
||||
|
||||
### 🏪 Inventory System (Phase 2 Complete):
|
||||
✅ Domain entities (InventoryItem, StockMovement, Warehouse)
|
||||
✅ Multi-warehouse inventory management
|
||||
✅ Stock reservation system for orders
|
||||
✅ 8 movement types with complete audit trail
|
||||
✅ Repository pattern with 25+ methods per repository
|
||||
✅ CQRS with 17 commands and 35 queries
|
||||
✅ 52 MediatR handlers with business logic
|
||||
✅ Low stock and out-of-stock alerts
|
||||
✅ Advanced analytics (top products, daily volume)
|
||||
✅ Bulk operations support
|
||||
✅ Transaction-safe operations with rollback
|
||||
✅ DI container configuration
|
||||
|
||||
---
|
||||
|
||||
## 👥 Team
|
||||
|
||||
**Development**: FourSat Team
|
||||
**Last Updated**: 2025-12-01
|
||||
**Last Updated**: January 2026
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Docs moved to /totalDoc — see totalDoc/INDEX.md
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2",
|
||||
"name": "hushyar",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "12336342",
|
||||
"_collection_link": "https://abbas-rahimzadeh-postman.postman.co/workspace/public-workspace~57c702f9-fed1-4b27-b507-0b0252812bf7/collection/12336342-a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2?action=share&source=collection_link&creator=12336342"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "v1",
|
||||
"item": [
|
||||
{
|
||||
"name": "organization",
|
||||
"item": [
|
||||
{
|
||||
"name": "register-user",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
},
|
||||
"description": "Register a new user or get existing user via organization API key. If user is new or has no organization, they will be assigned to this organization and their wallet will be charged with the organization's user_credit."
|
||||
},
|
||||
"response": [
|
||||
{
|
||||
"name": "success - new user",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": true,\n \"credit_charged\": 100.0\n}"
|
||||
},
|
||||
{
|
||||
"name": "success - existing user",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": false,\n \"credit_charged\": 0.0\n}"
|
||||
},
|
||||
{
|
||||
"name": "invalid api key",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "invalid-api-key",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Unauthorized",
|
||||
"code": 401,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"error_code\": \"INVALID_API_KEY\",\n \"message\": \"Invalid API key\",\n \"context\": {\n \"api_key_prefix\": \"invalid-\"\n }\n}"
|
||||
},
|
||||
{
|
||||
"name": "organization disabled",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Forbidden",
|
||||
"code": 403,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"error_code\": \"ORGANIZATION_DISABLED\",\n \"message\": \"Organization is disabled\",\n \"context\": {\n \"organization_id\": 1\n }\n}"
|
||||
},
|
||||
{
|
||||
"name": "organization expired",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Forbidden",
|
||||
"code": 403,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"error_code\": \"ORGANIZATION_EXPIRED\",\n \"message\": \"Organization has expired\",\n \"context\": {\n \"organization_id\": 1,\n \"expires_at\": \"2024-01-01T00:00:00\"\n }\n}"
|
||||
},
|
||||
{
|
||||
"name": "invalid mobile format",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-API-Key",
|
||||
"value": "{{organizationApiKey}}",
|
||||
"description": "Organization API Key",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"01234567890\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Unprocessable Entity",
|
||||
"code": 422,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"mobile_number\": \"String should match pattern '^09\\\\d{9}$'\"\n}"
|
||||
},
|
||||
{
|
||||
"name": "missing api key header",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseURL}}/api/v1/organizations/register-user",
|
||||
"host": [
|
||||
"{{baseURL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"organizations",
|
||||
"register-user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "Unprocessable Entity",
|
||||
"code": 422,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [
|
||||
{
|
||||
"key": "content-type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"cookie": [],
|
||||
"body": "{\n \"detail\": [\n {\n \"type\": \"missing\",\n \"loc\": [\"header\", \"x-api-key\"],\n \"msg\": \"Field required\",\n \"input\": null\n }\n ]\n}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"packages": {},
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"packages": {},
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseURL",
|
||||
"value": "http://127.0.0.0:8000",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "access_token",
|
||||
"value": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "accessToken",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "refreshToken",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "organizationApiKey",
|
||||
"value": "",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Command برای آپدیت یا ایجاد نسخه جدید اپلیکیشن
|
||||
/// </summary>
|
||||
public record UpdateAppVersionCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// نام اپلیکیشن (FrontOffice, BackOffice, MobileApp)
|
||||
/// </summary>
|
||||
public string AppName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// شماره نسخه جدید (مثلاً 1.2.3)
|
||||
/// </summary>
|
||||
public string CurrentVersion { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// حداقل نسخه مورد نیاز
|
||||
/// </summary>
|
||||
public string? MinRequiredVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// آیا کش کامل باید پاک بشه؟
|
||||
/// </summary>
|
||||
public bool RequiresFullCacheClear { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// پیام آپدیت
|
||||
/// </summary>
|
||||
public string? UpdateMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// توضیحات تغییرات این نسخه
|
||||
/// </summary>
|
||||
public string? ReleaseNotes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// دلیل آپدیت (برای لاگ)
|
||||
/// </summary>
|
||||
public string? UpdateReason { get; init; }
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
using CMSMicroservice.Domain.Entities.Configuration;
|
||||
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Handler برای آپدیت یا ایجاد نسخه جدید اپلیکیشن
|
||||
/// </summary>
|
||||
public class UpdateAppVersionCommandHandler : IRequestHandler<UpdateAppVersionCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<UpdateAppVersionCommandHandler> _logger;
|
||||
|
||||
public UpdateAppVersionCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ILogger<UpdateAppVersionCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(UpdateAppVersionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن نسخه موجود برای این اپ
|
||||
var existingVersion = await _context.AppVersions
|
||||
.Where(v => v.AppName == request.AppName && !v.IsDeleted)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existingVersion != null)
|
||||
{
|
||||
// آپدیت نسخه موجود
|
||||
existingVersion.CurrentVersion = request.CurrentVersion;
|
||||
existingVersion.MinRequiredVersion = request.MinRequiredVersion ?? request.CurrentVersion;
|
||||
existingVersion.RequiresFullCacheClear = request.RequiresFullCacheClear;
|
||||
existingVersion.UpdateMessage = request.UpdateMessage;
|
||||
existingVersion.ReleaseNotes = request.ReleaseNotes;
|
||||
|
||||
_logger.LogInformation(
|
||||
"App version updated: {AppName} v{Version}, CacheClear={CacheClear}, Reason={Reason}",
|
||||
request.AppName, request.CurrentVersion, request.RequiresFullCacheClear, request.UpdateReason);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ایجاد نسخه جدید
|
||||
var newVersion = new AppVersion
|
||||
{
|
||||
AppName = request.AppName,
|
||||
CurrentVersion = request.CurrentVersion,
|
||||
MinRequiredVersion = request.MinRequiredVersion ?? request.CurrentVersion,
|
||||
RequiresFullCacheClear = request.RequiresFullCacheClear,
|
||||
UpdateMessage = request.UpdateMessage,
|
||||
ReleaseNotes = request.ReleaseNotes,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
_context.AppVersions.Add(newVersion);
|
||||
|
||||
_logger.LogInformation(
|
||||
"New app version created: {AppName} v{Version}, CacheClear={CacheClear}, Reason={Reason}",
|
||||
request.AppName, request.CurrentVersion, request.RequiresFullCacheClear, request.UpdateReason);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
|
||||
|
||||
/// <summary>
|
||||
/// Query برای دریافت همه نسخههای اپلیکیشنها
|
||||
/// </summary>
|
||||
public record GetAllAppVersionsQuery(bool IncludeInactive = false) : IRequest<List<AppVersionItemDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// DTO برای هر آیتم نسخه اپلیکیشن
|
||||
/// </summary>
|
||||
public record AppVersionItemDto
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string AppName { get; init; } = string.Empty;
|
||||
public string CurrentVersion { get; init; } = string.Empty;
|
||||
public string MinRequiredVersion { get; init; } = string.Empty;
|
||||
public bool RequiresFullCacheClear { get; init; }
|
||||
public string? UpdateMessage { get; init; }
|
||||
public string? ReleaseNotes { get; init; }
|
||||
public bool IsActive { get; init; }
|
||||
public DateTime Created { get; init; }
|
||||
public DateTime? LastModified { get; init; }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
|
||||
|
||||
/// <summary>
|
||||
/// Handler برای دریافت همه نسخههای اپلیکیشنها
|
||||
/// </summary>
|
||||
public class GetAllAppVersionsQueryHandler : IRequestHandler<GetAllAppVersionsQuery, List<AppVersionItemDto>>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetAllAppVersionsQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<List<AppVersionItemDto>> Handle(GetAllAppVersionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.AppVersions
|
||||
.Where(v => !v.IsDeleted);
|
||||
|
||||
if (!request.IncludeInactive)
|
||||
{
|
||||
query = query.Where(v => v.IsActive);
|
||||
}
|
||||
|
||||
var versions = await query
|
||||
.OrderBy(v => v.AppName)
|
||||
.Select(v => new AppVersionItemDto
|
||||
{
|
||||
Id = v.Id,
|
||||
AppName = v.AppName,
|
||||
CurrentVersion = v.CurrentVersion,
|
||||
MinRequiredVersion = v.MinRequiredVersion,
|
||||
RequiresFullCacheClear = v.RequiresFullCacheClear,
|
||||
UpdateMessage = v.UpdateMessage,
|
||||
ReleaseNotes = v.ReleaseNotes,
|
||||
IsActive = v.IsActive,
|
||||
Created = v.Created,
|
||||
LastModified = v.LastModified
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return versions;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Query برای دریافت آخرین نسخه یک اپلیکیشن
|
||||
/// </summary>
|
||||
public record GetAppVersionQuery(string AppName, string? CurrentClientVersion = null) : IRequest<AppVersionDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// DTO برای اطلاعات نسخه اپلیکیشن
|
||||
/// </summary>
|
||||
public record AppVersionDto
|
||||
{
|
||||
public bool Found { get; init; }
|
||||
public string AppName { get; init; } = string.Empty;
|
||||
public string CurrentVersion { get; init; } = string.Empty;
|
||||
public string MinRequiredVersion { get; init; } = string.Empty;
|
||||
public bool RequiresFullCacheClear { get; init; }
|
||||
public bool RequiresUpdate { get; init; }
|
||||
public string? UpdateMessage { get; init; }
|
||||
public string? ReleaseNotes { get; init; }
|
||||
public DateTime? LastUpdated { get; init; }
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using CMSMicroservice.Domain.Entities.Configuration;
|
||||
|
||||
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Handler برای دریافت آخرین نسخه اپلیکیشن
|
||||
/// </summary>
|
||||
public class GetAppVersionQueryHandler : IRequestHandler<GetAppVersionQuery, AppVersionDto?>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetAppVersionQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<AppVersionDto?> Handle(GetAppVersionQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var appVersion = await _context.AppVersions
|
||||
.Where(v => v.AppName == request.AppName && v.IsActive && !v.IsDeleted)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (appVersion == null)
|
||||
{
|
||||
return new AppVersionDto
|
||||
{
|
||||
Found = false,
|
||||
AppName = request.AppName
|
||||
};
|
||||
}
|
||||
|
||||
// مقایسه ورژن کلاینت با حداقل نسخه مورد نیاز
|
||||
bool requiresUpdate = false;
|
||||
if (!string.IsNullOrEmpty(request.CurrentClientVersion) && !string.IsNullOrEmpty(appVersion.MinRequiredVersion))
|
||||
{
|
||||
requiresUpdate = CompareVersions(request.CurrentClientVersion, appVersion.MinRequiredVersion) < 0;
|
||||
}
|
||||
|
||||
return new AppVersionDto
|
||||
{
|
||||
Found = true,
|
||||
AppName = appVersion.AppName,
|
||||
CurrentVersion = appVersion.CurrentVersion,
|
||||
MinRequiredVersion = appVersion.MinRequiredVersion,
|
||||
RequiresFullCacheClear = appVersion.RequiresFullCacheClear,
|
||||
RequiresUpdate = requiresUpdate,
|
||||
UpdateMessage = appVersion.UpdateMessage,
|
||||
ReleaseNotes = appVersion.ReleaseNotes,
|
||||
LastUpdated = appVersion.LastModified ?? appVersion.Created
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// مقایسه دو نسخه (مثلاً 1.2.3 با 1.3.0)
|
||||
/// برگشت: منفی = اولی کوچکتر، مثبت = اولی بزرگتر، صفر = برابر
|
||||
/// </summary>
|
||||
private static int CompareVersions(string version1, string version2)
|
||||
{
|
||||
var v1Parts = version1.Split('.').Select(s => int.TryParse(s, out var n) ? n : 0).ToArray();
|
||||
var v2Parts = version2.Split('.').Select(s => int.TryParse(s, out var n) ? n : 0).ToArray();
|
||||
|
||||
var maxLen = Math.Max(v1Parts.Length, v2Parts.Length);
|
||||
|
||||
for (int i = 0; i < maxLen; i++)
|
||||
{
|
||||
var v1 = i < v1Parts.Length ? v1Parts[i] : 0;
|
||||
var v2 = i < v2Parts.Length ? v2Parts[i] : 0;
|
||||
|
||||
if (v1 != v2)
|
||||
return v1.CompareTo(v2);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
|
||||
|
||||
public class CreateBlogCategoryCommand : IRequest<long>
|
||||
{
|
||||
public string Title { get; set; } = default!;
|
||||
public string Slug { get; set; } = default!;
|
||||
public string? Description { get; set; }
|
||||
public string? IconName { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
|
||||
|
||||
public class CreateBlogCategoryCommandHandler : IRequestHandler<CreateBlogCategoryCommand, long>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public CreateBlogCategoryCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<long> Handle(CreateBlogCategoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = new BlogCategory
|
||||
{
|
||||
Title = request.Title,
|
||||
Slug = request.Slug.ToLower(),
|
||||
Description = request.Description,
|
||||
IconName = request.IconName,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
_context.BlogCategories.Add(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
|
||||
|
||||
public class CreateBlogCategoryCommandValidator : AbstractValidator<CreateBlogCategoryCommand>
|
||||
{
|
||||
public CreateBlogCategoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty().WithMessage("عنوان دستهبندی الزامی است")
|
||||
.MaximumLength(200).WithMessage("عنوان دستهبندی حداکثر ۲۰۰ کاراکتر");
|
||||
|
||||
RuleFor(x => x.Slug)
|
||||
.NotEmpty().WithMessage("اسلاگ الزامی است")
|
||||
.MaximumLength(200).WithMessage("اسلاگ حداکثر ۲۰۰ کاراکتر")
|
||||
.Matches(@"^[a-z0-9\-]+$").WithMessage("اسلاگ فقط شامل حروف کوچک، اعداد و خط تیره");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000).WithMessage("توضیحات حداکثر ۱۰۰۰ کاراکتر");
|
||||
|
||||
RuleFor(x => x.IconName)
|
||||
.MaximumLength(100).WithMessage("نام آیکون حداکثر ۱۰۰ کاراکتر");
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.DeleteBlogCategory;
|
||||
|
||||
public class DeleteBlogCategoryCommand : IRequest<Unit>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.DeleteBlogCategory;
|
||||
|
||||
public class DeleteBlogCategoryCommandHandler : IRequestHandler<DeleteBlogCategoryCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public DeleteBlogCategoryCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(DeleteBlogCategoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _context.BlogCategories.FindAsync(new object[] { request.Id }, cancellationToken);
|
||||
if (entity == null || entity.IsDeleted)
|
||||
throw new NotFoundException(nameof(BlogCategory), request.Id);
|
||||
|
||||
entity.IsDeleted = true;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
|
||||
|
||||
public class UpdateBlogCategoryCommand : IRequest<Unit>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = default!;
|
||||
public string Slug { get; set; } = default!;
|
||||
public string? Description { get; set; }
|
||||
public string? IconName { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
|
||||
|
||||
public class UpdateBlogCategoryCommandHandler : IRequestHandler<UpdateBlogCategoryCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public UpdateBlogCategoryCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(UpdateBlogCategoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _context.BlogCategories.FindAsync(new object[] { request.Id }, cancellationToken);
|
||||
if (entity == null || entity.IsDeleted)
|
||||
throw new NotFoundException(nameof(BlogCategory), request.Id);
|
||||
|
||||
entity.Title = request.Title;
|
||||
entity.Slug = request.Slug.ToLower();
|
||||
entity.Description = request.Description;
|
||||
entity.IconName = request.IconName;
|
||||
entity.SortOrder = request.SortOrder;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
|
||||
|
||||
public class UpdateBlogCategoryCommandValidator : AbstractValidator<UpdateBlogCategoryCommand>
|
||||
{
|
||||
public UpdateBlogCategoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0).WithMessage("شناسه دستهبندی نامعتبر است");
|
||||
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty().WithMessage("عنوان دستهبندی الزامی است")
|
||||
.MaximumLength(200).WithMessage("عنوان دستهبندی حداکثر ۲۰۰ کاراکتر");
|
||||
|
||||
RuleFor(x => x.Slug)
|
||||
.NotEmpty().WithMessage("اسلاگ الزامی است")
|
||||
.MaximumLength(200).WithMessage("اسلاگ حداکثر ۲۰۰ کاراکتر")
|
||||
.Matches(@"^[a-z0-9\-]+$").WithMessage("اسلاگ فقط شامل حروف کوچک، اعداد و خط تیره");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000).WithMessage("توضیحات حداکثر ۱۰۰۰ کاراکتر");
|
||||
|
||||
RuleFor(x => x.IconName)
|
||||
.MaximumLength(100).WithMessage("نام آیکون حداکثر ۱۰۰ کاراکتر");
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetActiveBlogCategories;
|
||||
|
||||
public class GetActiveBlogCategoriesQuery : IRequest<List<BlogCategoryDto>>
|
||||
{
|
||||
}
|
||||
|
||||
public class GetActiveBlogCategoriesQueryHandler : IRequestHandler<GetActiveBlogCategoriesQuery, List<BlogCategoryDto>>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetActiveBlogCategoriesQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<List<BlogCategoryDto>> Handle(GetActiveBlogCategoriesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var categories = await _context.BlogCategories
|
||||
.Include(x => x.BlogPostCategories)
|
||||
.Where(x => !x.IsDeleted && x.IsActive)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Title)
|
||||
.Select(x => new BlogCategoryDto
|
||||
{
|
||||
Id = x.Id,
|
||||
Title = x.Title,
|
||||
Slug = x.Slug,
|
||||
Description = x.Description,
|
||||
IconName = x.IconName,
|
||||
SortOrder = x.SortOrder,
|
||||
IsActive = x.IsActive,
|
||||
PostCount = x.BlogPostCategories.Count,
|
||||
Created = x.Created,
|
||||
LastModified = x.LastModified
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return categories;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
|
||||
|
||||
public class GetAllBlogCategoriesQuery : IRequest<GetAllBlogCategoriesResponseDto>
|
||||
{
|
||||
public int PageNumber { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 20;
|
||||
public string? SearchTerm { get; set; }
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
|
||||
|
||||
public class GetAllBlogCategoriesQueryHandler : IRequestHandler<GetAllBlogCategoriesQuery, GetAllBlogCategoriesResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetAllBlogCategoriesQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<GetAllBlogCategoriesResponseDto> Handle(GetAllBlogCategoriesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.BlogCategories
|
||||
.Include(x => x.BlogPostCategories)
|
||||
.Where(x => !x.IsDeleted);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.SearchTerm))
|
||||
{
|
||||
var term = request.SearchTerm.ToLower();
|
||||
query = query.Where(x => x.Title.ToLower().Contains(term) || x.Slug.ToLower().Contains(term));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var categories = await query
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Title)
|
||||
.Skip((request.PageNumber - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(x => new BlogCategoryDto
|
||||
{
|
||||
Id = x.Id,
|
||||
Title = x.Title,
|
||||
Slug = x.Slug,
|
||||
Description = x.Description,
|
||||
IconName = x.IconName,
|
||||
SortOrder = x.SortOrder,
|
||||
IsActive = x.IsActive,
|
||||
PostCount = x.BlogPostCategories.Count,
|
||||
Created = x.Created,
|
||||
LastModified = x.LastModified
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var metaData = new MetaData
|
||||
{
|
||||
TotalCount = totalCount,
|
||||
PageSize = request.PageSize,
|
||||
CurrentPage = request.PageNumber,
|
||||
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||
HasPrevious = request.PageNumber > 1
|
||||
};
|
||||
|
||||
return new GetAllBlogCategoriesResponseDto { MetaData = metaData, Models = categories };
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
|
||||
|
||||
public class GetAllBlogCategoriesResponseDto
|
||||
{
|
||||
public MetaData MetaData { get; set; } = default!;
|
||||
public List<BlogCategoryDto> Models { get; set; } = new();
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||
|
||||
public class BlogCategoryDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = default!;
|
||||
public string Slug { get; set; } = default!;
|
||||
public string? Description { get; set; }
|
||||
public string? IconName { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int PostCount { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime? LastModified { get; set; }
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||
|
||||
public class GetBlogCategoryQuery : IRequest<BlogCategoryDto>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||
|
||||
public class GetBlogCategoryQueryHandler : IRequestHandler<GetBlogCategoryQuery, BlogCategoryDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetBlogCategoryQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<BlogCategoryDto> Handle(GetBlogCategoryQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _context.BlogCategories
|
||||
.Include(x => x.BlogPostCategories)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
throw new NotFoundException(nameof(BlogCategory), request.Id);
|
||||
|
||||
return new BlogCategoryDto
|
||||
{
|
||||
Id = entity.Id,
|
||||
Title = entity.Title,
|
||||
Slug = entity.Slug,
|
||||
Description = entity.Description,
|
||||
IconName = entity.IconName,
|
||||
SortOrder = entity.SortOrder,
|
||||
IsActive = entity.IsActive,
|
||||
PostCount = entity.BlogPostCategories.Count,
|
||||
Created = entity.Created,
|
||||
LastModified = entity.LastModified
|
||||
};
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.ArchiveBlogPost;
|
||||
|
||||
public class ArchiveBlogPostCommand : IRequest<ArchiveBlogPostResult>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
|
||||
public class ArchiveBlogPostResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.ArchiveBlogPost;
|
||||
|
||||
public class ArchiveBlogPostCommandHandler : IRequestHandler<ArchiveBlogPostCommand, ArchiveBlogPostResult>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<ArchiveBlogPostCommandHandler> _logger;
|
||||
|
||||
public ArchiveBlogPostCommandHandler(IApplicationDbContext context, ILogger<ArchiveBlogPostCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ArchiveBlogPostResult> Handle(ArchiveBlogPostCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||
|
||||
post.Status = BlogPostStatus.Archived;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Blog post archived. Id: {Id}, Title: {Title}", post.Id, post.Title);
|
||||
|
||||
return new ArchiveBlogPostResult { Success = true, Message = "مقاله آرشیو شد" };
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
|
||||
|
||||
/// <summary>
|
||||
/// دستور ایجاد مقاله جدید
|
||||
/// </summary>
|
||||
public class CreateBlogPostCommand : IRequest<long>
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
public string? FeaturedImagePath { get; set; }
|
||||
public string? FeaturedImageThumbnailPath { get; set; }
|
||||
public List<long> CategoryIds { get; set; } = new();
|
||||
public List<long> TagIds { get; set; } = new();
|
||||
public bool IsFeatured { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
// Image upload properties
|
||||
public byte[]? ImageFileBytes { get; set; }
|
||||
public string? ImageFileMime { get; set; }
|
||||
public string? ImageFileName { get; set; }
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
using CMSMicroservice.Application.Common.FileManager;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
|
||||
|
||||
public class CreateBlogPostCommandHandler : IRequestHandler<CreateBlogPostCommand, long>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IFileManager _fileManager;
|
||||
private readonly ILogger<CreateBlogPostCommandHandler> _logger;
|
||||
|
||||
public CreateBlogPostCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser,
|
||||
IFileManager fileManager,
|
||||
ILogger<CreateBlogPostCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
_fileManager = fileManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<long> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentUserId = _currentUser.UserId;
|
||||
if (string.IsNullOrEmpty(currentUserId) || !long.TryParse(currentUserId, out var authorUserId))
|
||||
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
|
||||
|
||||
var post = new BlogPost
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
Slug = string.IsNullOrWhiteSpace(request.Slug)
|
||||
? $"post-{Guid.NewGuid():N}".Substring(0, 20)
|
||||
: request.Slug.Trim().ToLower(),
|
||||
Summary = request.Summary?.Trim(),
|
||||
HtmlContent = request.HtmlContent,
|
||||
FeaturedImagePath = request.FeaturedImagePath,
|
||||
FeaturedImageThumbnailPath = request.FeaturedImageThumbnailPath,
|
||||
Status = BlogPostStatus.Draft,
|
||||
AuthorUserId = authorUserId,
|
||||
IsFeatured = request.IsFeatured,
|
||||
SortOrder = request.SortOrder,
|
||||
ViewCount = 0
|
||||
};
|
||||
|
||||
// آپلود تصویر شاخص (اگر فایل ارسال شده باشد)
|
||||
if (request.ImageFileBytes is { Length: > 0 })
|
||||
{
|
||||
var result = await _fileManager.UploadImageAsync(
|
||||
"Images/BlogPosts",
|
||||
request.ImageFileBytes,
|
||||
request.ImageFileMime ?? "image/jpeg",
|
||||
request.ImageFileName,
|
||||
cancellationToken);
|
||||
|
||||
post.FeaturedImagePath = result.Main.Path;
|
||||
post.FeaturedImageThumbnailPath = result.Thumbnail.Path;
|
||||
}
|
||||
|
||||
_context.BlogPosts.Add(post);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// افزودن دستهبندیها
|
||||
foreach (var categoryId in request.CategoryIds)
|
||||
{
|
||||
_context.BlogPostCategories.Add(new BlogPostCategory
|
||||
{
|
||||
BlogPostId = post.Id,
|
||||
BlogCategoryId = categoryId
|
||||
});
|
||||
}
|
||||
|
||||
// افزودن تگها
|
||||
foreach (var tagId in request.TagIds)
|
||||
{
|
||||
_context.BlogPostTags.Add(new BlogPostTag
|
||||
{
|
||||
BlogPostId = post.Id,
|
||||
TagId = tagId
|
||||
});
|
||||
}
|
||||
|
||||
if (request.CategoryIds.Any() || request.TagIds.Any())
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Blog post created. Id: {Id}, Title: {Title}, Author: {Author}",
|
||||
post.Id, post.Title, authorUserId);
|
||||
|
||||
return post.Id;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
|
||||
|
||||
public class CreateBlogPostCommandValidator : AbstractValidator<CreateBlogPostCommand>
|
||||
{
|
||||
public CreateBlogPostCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty().WithMessage("عنوان مقاله الزامی است")
|
||||
.MaximumLength(200).WithMessage("عنوان نمیتواند بیشتر از 200 کاراکتر باشد");
|
||||
|
||||
RuleFor(x => x.Slug)
|
||||
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
|
||||
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
||||
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
|
||||
|
||||
RuleFor(x => x.Summary)
|
||||
.MaximumLength(500).WithMessage("خلاصه نمیتواند بیشتر از 500 کاراکتر باشد")
|
||||
.When(x => !string.IsNullOrEmpty(x.Summary));
|
||||
|
||||
RuleFor(x => x.HtmlContent)
|
||||
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.DeleteBlogPost;
|
||||
|
||||
public class DeleteBlogPostCommand : IRequest<Unit>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.DeleteBlogPost;
|
||||
|
||||
public class DeleteBlogPostCommandHandler : IRequestHandler<DeleteBlogPostCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<DeleteBlogPostCommandHandler> _logger;
|
||||
|
||||
public DeleteBlogPostCommandHandler(IApplicationDbContext context, ILogger<DeleteBlogPostCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||
|
||||
post.IsDeleted = true;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Blog post soft-deleted. Id: {Id}", post.Id);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.IncrementViewCount;
|
||||
|
||||
public class IncrementViewCountCommand : IRequest<Unit>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.IncrementViewCount;
|
||||
|
||||
public class IncrementViewCountCommandHandler : IRequestHandler<IncrementViewCountCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public IncrementViewCountCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(IncrementViewCountCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
|
||||
if (post != null)
|
||||
{
|
||||
post.ViewCount++;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.PublishBlogPost;
|
||||
|
||||
public class PublishBlogPostCommand : IRequest<PublishBlogPostResult>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
|
||||
public class PublishBlogPostResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.PublishBlogPost;
|
||||
|
||||
public class PublishBlogPostCommandHandler : IRequestHandler<PublishBlogPostCommand, PublishBlogPostResult>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<PublishBlogPostCommandHandler> _logger;
|
||||
|
||||
public PublishBlogPostCommandHandler(IApplicationDbContext context, ILogger<PublishBlogPostCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PublishBlogPostResult> Handle(PublishBlogPostCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||
|
||||
if (post.Status == BlogPostStatus.Published)
|
||||
return new PublishBlogPostResult { Success = false, Message = "مقاله قبلاً منتشر شده است" };
|
||||
|
||||
post.Status = BlogPostStatus.Published;
|
||||
post.PublishedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Blog post published. Id: {Id}, Title: {Title}", post.Id, post.Title);
|
||||
|
||||
return new PublishBlogPostResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "مقاله با موفقیت منتشر شد",
|
||||
PublishedAt = post.PublishedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
|
||||
|
||||
public class UpdateBlogPostCommand : IRequest<Unit>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
public string? FeaturedImagePath { get; set; }
|
||||
public string? FeaturedImageThumbnailPath { get; set; }
|
||||
public List<long> CategoryIds { get; set; } = new();
|
||||
public List<long> TagIds { get; set; } = new();
|
||||
public bool IsFeatured { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
// Image upload properties
|
||||
public byte[]? ImageFileBytes { get; set; }
|
||||
public string? ImageFileMime { get; set; }
|
||||
public string? ImageFileName { get; set; }
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using CMSMicroservice.Application.Common.FileManager;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
|
||||
|
||||
public class UpdateBlogPostCommandHandler : IRequestHandler<UpdateBlogPostCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IFileManager _fileManager;
|
||||
private readonly ILogger<UpdateBlogPostCommandHandler> _logger;
|
||||
|
||||
public UpdateBlogPostCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IFileManager fileManager,
|
||||
ILogger<UpdateBlogPostCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_fileManager = fileManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(UpdateBlogPostCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||
|
||||
post.Title = request.Title.Trim();
|
||||
// حفظ slug قبلی اگر مقدار جدید خالی باشد
|
||||
if (!string.IsNullOrWhiteSpace(request.Slug))
|
||||
post.Slug = request.Slug.Trim().ToLower();
|
||||
post.Summary = request.Summary?.Trim();
|
||||
post.HtmlContent = request.HtmlContent;
|
||||
post.FeaturedImagePath = request.FeaturedImagePath;
|
||||
post.FeaturedImageThumbnailPath = request.FeaturedImageThumbnailPath;
|
||||
post.IsFeatured = request.IsFeatured;
|
||||
post.SortOrder = request.SortOrder;
|
||||
|
||||
// آپلود تصویر شاخص (اگر فایل جدید ارسال شده باشد)
|
||||
if (request.ImageFileBytes is { Length: > 0 })
|
||||
{
|
||||
var result = await _fileManager.UploadImageAsync(
|
||||
"Images/BlogPosts",
|
||||
request.ImageFileBytes,
|
||||
request.ImageFileMime ?? "image/jpeg",
|
||||
request.ImageFileName,
|
||||
cancellationToken);
|
||||
|
||||
post.FeaturedImagePath = result.Main.Path;
|
||||
post.FeaturedImageThumbnailPath = result.Thumbnail.Path;
|
||||
}
|
||||
|
||||
// بروزرسانی دستهبندیها
|
||||
var existingCategories = await _context.BlogPostCategories
|
||||
.Where(x => x.BlogPostId == post.Id).ToListAsync(cancellationToken);
|
||||
_context.BlogPostCategories.RemoveRange(existingCategories);
|
||||
foreach (var categoryId in request.CategoryIds)
|
||||
{
|
||||
_context.BlogPostCategories.Add(new BlogPostCategory
|
||||
{
|
||||
BlogPostId = post.Id,
|
||||
BlogCategoryId = categoryId
|
||||
});
|
||||
}
|
||||
|
||||
// بروزرسانی تگها
|
||||
var existingTags = await _context.BlogPostTags
|
||||
.Where(x => x.BlogPostId == post.Id).ToListAsync(cancellationToken);
|
||||
_context.BlogPostTags.RemoveRange(existingTags);
|
||||
foreach (var tagId in request.TagIds)
|
||||
{
|
||||
_context.BlogPostTags.Add(new BlogPostTag
|
||||
{
|
||||
BlogPostId = post.Id,
|
||||
TagId = tagId
|
||||
});
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Blog post updated. Id: {Id}, Title: {Title}", post.Id, post.Title);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
|
||||
|
||||
public class UpdateBlogPostCommandValidator : AbstractValidator<UpdateBlogPostCommand>
|
||||
{
|
||||
public UpdateBlogPostCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id).GreaterThan(0).WithMessage("شناسه مقاله نامعتبر است");
|
||||
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty().WithMessage("عنوان مقاله الزامی است")
|
||||
.MaximumLength(200).WithMessage("عنوان نمیتواند بیشتر از 200 کاراکتر باشد");
|
||||
|
||||
RuleFor(x => x.Slug)
|
||||
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
|
||||
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
||||
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
|
||||
|
||||
RuleFor(x => x.HtmlContent)
|
||||
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
|
||||
|
||||
public class GetAllBlogPostsQuery : IRequest<GetAllBlogPostsResponseDto>
|
||||
{
|
||||
public int PageNumber { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 10;
|
||||
public string? SortBy { get; set; }
|
||||
public string? SearchTerm { get; set; }
|
||||
public BlogPostStatus? Status { get; set; }
|
||||
public long? CategoryId { get; set; }
|
||||
public bool? IsFeatured { get; set; }
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
|
||||
|
||||
public class GetAllBlogPostsQueryHandler : IRequestHandler<GetAllBlogPostsQuery, GetAllBlogPostsResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetAllBlogPostsQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<GetAllBlogPostsResponseDto> Handle(GetAllBlogPostsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.BlogPosts
|
||||
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||
.Where(x => !x.IsDeleted);
|
||||
|
||||
// فیلترها
|
||||
if (request.Status.HasValue)
|
||||
query = query.Where(x => x.Status == request.Status.Value);
|
||||
if (request.CategoryId.HasValue)
|
||||
query = query.Where(x => x.BlogPostCategories.Any(c => c.BlogCategoryId == request.CategoryId.Value));
|
||||
if (request.IsFeatured.HasValue)
|
||||
query = query.Where(x => x.IsFeatured == request.IsFeatured.Value);
|
||||
if (!string.IsNullOrEmpty(request.SearchTerm))
|
||||
{
|
||||
var term = request.SearchTerm.ToLower();
|
||||
query = query.Where(x => x.Title.ToLower().Contains(term) || (x.Summary != null && x.Summary.ToLower().Contains(term)));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
// مرتبسازی
|
||||
query = request.SortBy?.ToLower() switch
|
||||
{
|
||||
"title" => query.OrderBy(x => x.Title),
|
||||
"viewcount" => query.OrderByDescending(x => x.ViewCount),
|
||||
"publishedat" => query.OrderByDescending(x => x.PublishedAt),
|
||||
_ => query.OrderByDescending(x => x.Created)
|
||||
};
|
||||
|
||||
var posts = await query
|
||||
.Skip((request.PageNumber - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(x => new BlogPostListItemDto
|
||||
{
|
||||
Id = x.Id,
|
||||
Title = x.Title,
|
||||
Slug = x.Slug,
|
||||
Summary = x.Summary,
|
||||
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
|
||||
Status = (int)x.Status,
|
||||
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
|
||||
PublishedAt = x.PublishedAt,
|
||||
ViewCount = x.ViewCount,
|
||||
IsFeatured = x.IsFeatured,
|
||||
Created = x.Created,
|
||||
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||
{
|
||||
Id = c.BlogCategory.Id,
|
||||
Title = c.BlogCategory.Title,
|
||||
Slug = c.BlogCategory.Slug
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var metaData = new MetaData
|
||||
{
|
||||
TotalCount = totalCount,
|
||||
PageSize = request.PageSize,
|
||||
CurrentPage = request.PageNumber,
|
||||
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||
HasPrevious = request.PageNumber > 1
|
||||
};
|
||||
|
||||
return new GetAllBlogPostsResponseDto { MetaData = metaData, Models = posts };
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
|
||||
|
||||
public class GetAllBlogPostsResponseDto
|
||||
{
|
||||
public MetaData MetaData { get; set; } = new();
|
||||
public List<BlogPostListItemDto> Models { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BlogPostListItemDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
public string? FeaturedImageThumbnailPath { get; set; }
|
||||
public int Status { get; set; }
|
||||
public string StatusName { get; set; } = string.Empty;
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public int ViewCount { get; set; }
|
||||
public bool IsFeatured { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public List<BlogPostCategoryDto> Categories { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
|
||||
public class BlogPostDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
public string? FeaturedImagePath { get; set; }
|
||||
public string? FeaturedImageThumbnailPath { get; set; }
|
||||
public BlogPostStatus Status { get; set; }
|
||||
public string StatusName { get; set; } = string.Empty;
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public int ViewCount { get; set; }
|
||||
public long AuthorUserId { get; set; }
|
||||
public bool IsFeatured { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime? LastModified { get; set; }
|
||||
public List<BlogPostCategoryDto> Categories { get; set; } = new();
|
||||
public List<BlogPostTagDto> Tags { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BlogPostCategoryDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class BlogPostTagDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
|
||||
public class GetBlogPostQuery : IRequest<BlogPostDto>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
|
||||
public class GetBlogPostQueryHandler : IRequestHandler<GetBlogPostQuery, BlogPostDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetBlogPostQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<BlogPostDto> Handle(GetBlogPostQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await _context.BlogPosts
|
||||
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||
.Include(x => x.BlogPostTags).ThenInclude(x => x.Tag)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||
|
||||
return new BlogPostDto
|
||||
{
|
||||
Id = post.Id,
|
||||
Title = post.Title,
|
||||
Slug = post.Slug,
|
||||
Summary = post.Summary,
|
||||
HtmlContent = post.HtmlContent,
|
||||
FeaturedImagePath = post.FeaturedImagePath,
|
||||
FeaturedImageThumbnailPath = post.FeaturedImageThumbnailPath,
|
||||
Status = post.Status,
|
||||
StatusName = GetStatusName(post.Status),
|
||||
PublishedAt = post.PublishedAt,
|
||||
ViewCount = post.ViewCount,
|
||||
AuthorUserId = post.AuthorUserId,
|
||||
IsFeatured = post.IsFeatured,
|
||||
SortOrder = post.SortOrder,
|
||||
Created = post.Created,
|
||||
LastModified = post.LastModified,
|
||||
Categories = post.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||
{
|
||||
Id = c.BlogCategory.Id,
|
||||
Title = c.BlogCategory.Title,
|
||||
Slug = c.BlogCategory.Slug
|
||||
}).ToList(),
|
||||
Tags = post.BlogPostTags.Select(t => new BlogPostTagDto
|
||||
{
|
||||
Id = t.Tag.Id,
|
||||
Title = t.Tag.Title,
|
||||
Name = t.Tag.Name
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetStatusName(BlogPostStatus status) => status switch
|
||||
{
|
||||
BlogPostStatus.Draft => "پیشنویس",
|
||||
BlogPostStatus.Published => "منتشرشده",
|
||||
BlogPostStatus.Scheduled => "زمانبندیشده",
|
||||
BlogPostStatus.Archived => "آرشیو",
|
||||
_ => "نامشخص"
|
||||
};
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPostBySlug;
|
||||
|
||||
public class GetBlogPostBySlugQuery : IRequest<GetBlogPost.BlogPostDto>
|
||||
{
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPostBySlug;
|
||||
|
||||
public class GetBlogPostBySlugQueryHandler : IRequestHandler<GetBlogPostBySlugQuery, BlogPostDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetBlogPostBySlugQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<BlogPostDto> Handle(GetBlogPostBySlugQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await _context.BlogPosts
|
||||
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||
.Include(x => x.BlogPostTags).ThenInclude(x => x.Tag)
|
||||
.FirstOrDefaultAsync(x => x.Slug == request.Slug && !x.IsDeleted, cancellationToken)
|
||||
?? throw new KeyNotFoundException($"مقاله با نشانی '{request.Slug}' یافت نشد");
|
||||
|
||||
return new BlogPostDto
|
||||
{
|
||||
Id = post.Id,
|
||||
Title = post.Title,
|
||||
Slug = post.Slug,
|
||||
Summary = post.Summary,
|
||||
HtmlContent = post.HtmlContent,
|
||||
FeaturedImagePath = post.FeaturedImagePath,
|
||||
FeaturedImageThumbnailPath = post.FeaturedImageThumbnailPath,
|
||||
Status = post.Status,
|
||||
StatusName = GetBlogPostQueryHandler.GetStatusName(post.Status),
|
||||
PublishedAt = post.PublishedAt,
|
||||
ViewCount = post.ViewCount,
|
||||
AuthorUserId = post.AuthorUserId,
|
||||
IsFeatured = post.IsFeatured,
|
||||
SortOrder = post.SortOrder,
|
||||
Created = post.Created,
|
||||
LastModified = post.LastModified,
|
||||
Categories = post.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||
{
|
||||
Id = c.BlogCategory.Id,
|
||||
Title = c.BlogCategory.Title,
|
||||
Slug = c.BlogCategory.Slug
|
||||
}).ToList(),
|
||||
Tags = post.BlogPostTags.Select(t => new BlogPostTagDto
|
||||
{
|
||||
Id = t.Tag.Id,
|
||||
Title = t.Tag.Title,
|
||||
Name = t.Tag.Name
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetFeaturedBlogPosts;
|
||||
|
||||
public class GetFeaturedBlogPostsQuery : IRequest<List<GetAllBlogPosts.BlogPostListItemDto>>
|
||||
{
|
||||
public int Count { get; set; } = 5;
|
||||
}
|
||||
|
||||
public class GetFeaturedBlogPostsQueryHandler : IRequestHandler<GetFeaturedBlogPostsQuery, List<GetAllBlogPosts.BlogPostListItemDto>>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetFeaturedBlogPostsQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<List<GetAllBlogPosts.BlogPostListItemDto>> Handle(GetFeaturedBlogPostsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var posts = await _context.BlogPosts
|
||||
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||
.Where(x => !x.IsDeleted && x.Status == BlogPostStatus.Published && x.IsFeatured)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenByDescending(x => x.PublishedAt)
|
||||
.Take(request.Count)
|
||||
.Select(x => new GetAllBlogPosts.BlogPostListItemDto
|
||||
{
|
||||
Id = x.Id,
|
||||
Title = x.Title,
|
||||
Slug = x.Slug,
|
||||
Summary = x.Summary,
|
||||
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
|
||||
Status = (int)x.Status,
|
||||
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
|
||||
PublishedAt = x.PublishedAt,
|
||||
ViewCount = x.ViewCount,
|
||||
IsFeatured = x.IsFeatured,
|
||||
Created = x.Created,
|
||||
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||
{
|
||||
Id = c.BlogCategory.Id,
|
||||
Title = c.BlogCategory.Title,
|
||||
Slug = c.BlogCategory.Slug
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return posts;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetPublishedBlogPosts;
|
||||
|
||||
public class GetPublishedBlogPostsQuery : IRequest<GetAllBlogPosts.GetAllBlogPostsResponseDto>
|
||||
{
|
||||
public int PageNumber { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 10;
|
||||
public string? SearchTerm { get; set; }
|
||||
public long? CategoryId { get; set; }
|
||||
}
|
||||
|
||||
public class GetPublishedBlogPostsQueryHandler : IRequestHandler<GetPublishedBlogPostsQuery, GetAllBlogPosts.GetAllBlogPostsResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetPublishedBlogPostsQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<GetAllBlogPosts.GetAllBlogPostsResponseDto> Handle(GetPublishedBlogPostsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.BlogPosts
|
||||
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
|
||||
.Where(x => !x.IsDeleted && x.Status == BlogPostStatus.Published);
|
||||
|
||||
if (request.CategoryId.HasValue)
|
||||
query = query.Where(x => x.BlogPostCategories.Any(c => c.BlogCategoryId == request.CategoryId.Value));
|
||||
if (!string.IsNullOrEmpty(request.SearchTerm))
|
||||
{
|
||||
var term = request.SearchTerm.ToLower();
|
||||
query = query.Where(x => x.Title.ToLower().Contains(term) || (x.Summary != null && x.Summary.ToLower().Contains(term)));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var posts = await query
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.Skip((request.PageNumber - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.Select(x => new GetAllBlogPosts.BlogPostListItemDto
|
||||
{
|
||||
Id = x.Id,
|
||||
Title = x.Title,
|
||||
Slug = x.Slug,
|
||||
Summary = x.Summary,
|
||||
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
|
||||
Status = (int)x.Status,
|
||||
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
|
||||
PublishedAt = x.PublishedAt,
|
||||
ViewCount = x.ViewCount,
|
||||
IsFeatured = x.IsFeatured,
|
||||
Created = x.Created,
|
||||
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
|
||||
{
|
||||
Id = c.BlogCategory.Id,
|
||||
Title = c.BlogCategory.Title,
|
||||
Slug = c.BlogCategory.Slug
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var metaData = new MetaData
|
||||
{
|
||||
TotalCount = totalCount,
|
||||
PageSize = request.PageSize,
|
||||
CurrentPage = request.PageNumber,
|
||||
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
|
||||
HasPrevious = request.PageNumber > 1
|
||||
};
|
||||
|
||||
return new GetAllBlogPosts.GetAllBlogPostsResponseDto { MetaData = metaData, Models = posts };
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
|
||||
|
||||
public class AddBlogPostImageCommand : IRequest<long>
|
||||
{
|
||||
public long BlogPostId { get; set; }
|
||||
public string ImagePath { get; set; } = default!;
|
||||
public string ThumbnailPath { get; set; } = default!;
|
||||
public string? AltText { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
|
||||
|
||||
public class AddBlogPostImageCommandHandler : IRequestHandler<AddBlogPostImageCommand, long>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public AddBlogPostImageCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<long> Handle(AddBlogPostImageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var blogPost = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.BlogPostId && !x.IsDeleted, cancellationToken);
|
||||
if (blogPost == null)
|
||||
throw new NotFoundException(nameof(BlogPost), request.BlogPostId);
|
||||
|
||||
var entity = new BlogPostImage
|
||||
{
|
||||
BlogPostId = request.BlogPostId,
|
||||
ImagePath = request.ImagePath,
|
||||
ThumbnailPath = request.ThumbnailPath,
|
||||
AltText = request.AltText,
|
||||
Caption = request.Caption,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
|
||||
_context.BlogPostImages.Add(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
|
||||
|
||||
public class AddBlogPostImageCommandValidator : AbstractValidator<AddBlogPostImageCommand>
|
||||
{
|
||||
public AddBlogPostImageCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BlogPostId)
|
||||
.GreaterThan(0).WithMessage("شناسه پست نامعتبر است");
|
||||
|
||||
RuleFor(x => x.ImagePath)
|
||||
.NotEmpty().WithMessage("مسیر تصویر الزامی است");
|
||||
|
||||
RuleFor(x => x.AltText)
|
||||
.MaximumLength(500).WithMessage("متن جایگزین حداکثر ۵۰۰ کاراکتر");
|
||||
|
||||
RuleFor(x => x.Caption)
|
||||
.MaximumLength(1000).WithMessage("عنوان تصویر حداکثر ۱۰۰۰ کاراکتر");
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.DeleteBlogPostImage;
|
||||
|
||||
public class DeleteBlogPostImageCommand : IRequest<Unit>
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.DeleteBlogPostImage;
|
||||
|
||||
public class DeleteBlogPostImageCommandHandler : IRequestHandler<DeleteBlogPostImageCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public DeleteBlogPostImageCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(DeleteBlogPostImageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _context.BlogPostImages.FindAsync(new object[] { request.Id }, cancellationToken);
|
||||
if (entity == null || entity.IsDeleted)
|
||||
throw new NotFoundException(nameof(BlogPostImage), request.Id);
|
||||
|
||||
entity.IsDeleted = true;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.ReorderBlogPostImages;
|
||||
|
||||
public class ReorderBlogPostImagesCommand : IRequest<Unit>
|
||||
{
|
||||
public List<ImageSortItem> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ImageSortItem
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.ReorderBlogPostImages;
|
||||
|
||||
public class ReorderBlogPostImagesCommandHandler : IRequestHandler<ReorderBlogPostImagesCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public ReorderBlogPostImagesCommandHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ReorderBlogPostImagesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var ids = request.Items.Select(x => x.Id).ToList();
|
||||
var images = await _context.BlogPostImages
|
||||
.Where(x => ids.Contains(x.Id) && !x.IsDeleted)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
var image = images.FirstOrDefault(x => x.Id == item.Id);
|
||||
if (image != null)
|
||||
image.SortOrder = item.SortOrder;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Queries.GetBlogPostImages;
|
||||
|
||||
public class GetBlogPostImagesQuery : IRequest<List<BlogPostImageDto>>
|
||||
{
|
||||
public long BlogPostId { get; set; }
|
||||
}
|
||||
|
||||
public class BlogPostImageDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long BlogPostId { get; set; }
|
||||
public string ImagePath { get; set; } = default!;
|
||||
public string? ThumbnailPath { get; set; }
|
||||
public string? AltText { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CMSMicroservice.Application.BlogPostImageCQ.Queries.GetBlogPostImages;
|
||||
|
||||
public class GetBlogPostImagesQueryHandler : IRequestHandler<GetBlogPostImagesQuery, List<BlogPostImageDto>>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public GetBlogPostImagesQueryHandler(IApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<List<BlogPostImageDto>> Handle(GetBlogPostImagesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var images = await _context.BlogPostImages
|
||||
.Where(x => x.BlogPostId == request.BlogPostId && !x.IsDeleted)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.Select(x => new BlogPostImageDto
|
||||
{
|
||||
Id = x.Id,
|
||||
BlogPostId = x.BlogPostId,
|
||||
ImagePath = x.ImagePath,
|
||||
ThumbnailPath = x.ThumbnailPath,
|
||||
AltText = x.AltText,
|
||||
Caption = x.Caption,
|
||||
SortOrder = x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return images;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.2" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
|
||||
|
||||
+12
-1
@@ -31,7 +31,18 @@ public class GetAllCategoryByFilterQueryHandler : IRequestHandler<GetAllCategory
|
||||
{
|
||||
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
|
||||
Models = await query.PaginatedListAsync(paginationState: request.PaginationState)
|
||||
.ProjectToType<GetAllCategoryByFilterResponseModel>().ToListAsync(cancellationToken)
|
||||
.Select(x => new GetAllCategoryByFilterResponseModel
|
||||
{
|
||||
Id = x.Id,
|
||||
Name = x.Name,
|
||||
Title = x.Title,
|
||||
Description = x.Description,
|
||||
ImagePath = x.ImagePath,
|
||||
ParentId = x.ParentId,
|
||||
IsActive = x.IsActive,
|
||||
SortOrder = x.SortOrder,
|
||||
ProductCount = x.ProductCategories.Count
|
||||
}).ToListAsync(cancellationToken)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -24,4 +24,6 @@ public class GetAllCategoryByFilterResponseDto
|
||||
public bool IsActive { get; set; }
|
||||
//ترتیب نمایش
|
||||
public int SortOrder { get; set; }
|
||||
//تعداد محصولات
|
||||
public int ProductCount { get; set; }
|
||||
}
|
||||
|
||||
+167
-16
@@ -1,5 +1,10 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Common;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Entities.Club;
|
||||
using CMSMicroservice.Domain.Entities.Commission;
|
||||
using CMSMicroservice.Domain.Entities.History;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -12,6 +17,8 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMember
|
||||
/// 1. کد OTP را تایید میکند
|
||||
/// 2. قرارداد را در جدول UserContract ثبت میکند
|
||||
/// 3. باشگاه مشتری را فعال میکند (IsActive = true)
|
||||
/// 4. مبلغ را به Pool هفته جاری اضافه میکند
|
||||
/// 5. ویژگیهای باشگاه را به کاربر اعطا میکند
|
||||
/// </summary>
|
||||
public class AcceptClubMembershipContractCommandHandler
|
||||
: IRequestHandler<AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponseDto>
|
||||
@@ -19,6 +26,8 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IHashService _hashService;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IWeekDefinitionRepository _weekRepository;
|
||||
private readonly ILogger<AcceptClubMembershipContractCommandHandler> _logger;
|
||||
|
||||
private const int MaxAttempts = 5;
|
||||
@@ -28,11 +37,15 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
IApplicationDbContext context,
|
||||
IConfiguration cfg,
|
||||
IHashService hashService,
|
||||
ICurrentUserService currentUser,
|
||||
IWeekDefinitionRepository weekRepository,
|
||||
ILogger<AcceptClubMembershipContractCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_cfg = cfg;
|
||||
_hashService = hashService;
|
||||
_currentUser = currentUser;
|
||||
_weekRepository = weekRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -40,20 +53,28 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
AcceptClubMembershipContractCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// خواندن UserId از JWT (امنتر از دریافت از کلاینت)
|
||||
if (!long.TryParse(_currentUser.UserId, out var userId))
|
||||
return new AcceptClubMembershipContractResponseDto
|
||||
{
|
||||
Success = false,
|
||||
Message = "کاربر احراز هویت نشده است"
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processing club membership contract for UserId: {UserId}",
|
||||
request.UserId
|
||||
userId
|
||||
);
|
||||
|
||||
// 1. دریافت کاربر
|
||||
var user = await _context.Users
|
||||
.Include(u => u.ClubMembership)
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("User not found: {UserId}", request.UserId);
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
_logger.LogWarning("User not found: {UserId}", userId);
|
||||
throw new NotFoundException(nameof(User), userId);
|
||||
}
|
||||
|
||||
// 2. بررسی خرید پکیج
|
||||
@@ -114,33 +135,150 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
};
|
||||
await _context.UserContracts.AddAsync(userContract, cancellationToken);
|
||||
|
||||
// 6. فعالسازی باشگاه مشتریان
|
||||
if (user.ClubMembership == null)
|
||||
// 6. دریافت مقادیر از SystemConstants (استاتیک)
|
||||
long giftValue = SystemConstants.ClubMembershipGiftValue;
|
||||
long activationFeeValue = SystemConstants.ClubActivationFee;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
|
||||
giftValue, activationFeeValue
|
||||
);
|
||||
|
||||
// 7. فعالسازی باشگاه مشتریان
|
||||
ClubMembership clubMembership;
|
||||
bool isNewMembership = user.ClubMembership == null;
|
||||
var activationDate = DateTime.Now;
|
||||
|
||||
if (isNewMembership)
|
||||
{
|
||||
user.ClubMembership = new Domain.Entities.Club.ClubMembership
|
||||
clubMembership = new ClubMembership
|
||||
{
|
||||
UserId = user.Id,
|
||||
IsActive = true,
|
||||
ActivatedAt = DateTime.Now,
|
||||
InitialContribution = 56_000_000,
|
||||
GiftValue = 25_200_000,
|
||||
ActivatedAt = activationDate,
|
||||
InitialContribution = activationFeeValue,
|
||||
GiftValue = giftValue,
|
||||
TotalEarned = 0,
|
||||
PurchaseMethod = user.PackagePurchaseMethod
|
||||
};
|
||||
await _context.ClubMemberships.AddAsync(user.ClubMembership, cancellationToken);
|
||||
await _context.ClubMemberships.AddAsync(clubMembership, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new club membership for UserId {UserId} via {Method}, GiftValue: {GiftValue}",
|
||||
user.Id,
|
||||
user.PackagePurchaseMethod,
|
||||
giftValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.ClubMembership.IsActive = true;
|
||||
user.ClubMembership.ActivatedAt = DateTime.Now;
|
||||
_context.ClubMemberships.Update(user.ClubMembership);
|
||||
clubMembership = user.ClubMembership!;
|
||||
clubMembership.IsActive = true;
|
||||
clubMembership.ActivatedAt = activationDate;
|
||||
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
|
||||
_context.ClubMemberships.Update(clubMembership);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Reactivated club membership for UserId {UserId}",
|
||||
user.Id
|
||||
);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. ثبت تاریخچه
|
||||
var history = new ClubMembershipHistory
|
||||
{
|
||||
ClubMembershipId = clubMembership.Id,
|
||||
UserId = clubMembership.UserId,
|
||||
OldIsActive = !isNewMembership && !user.ClubMembership!.IsActive,
|
||||
NewIsActive = true,
|
||||
Action = ClubMembershipAction.Activated,
|
||||
Reason = isNewMembership
|
||||
? $"Initial activation via contract signing - {user.PackagePurchaseMethod}"
|
||||
: $"Reactivated via contract signing - {user.PackagePurchaseMethod}",
|
||||
PerformedBy = _currentUser.GetPerformedBy()
|
||||
};
|
||||
|
||||
_context.ClubMembershipHistories.Add(history);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 9. اضافه کردن مبلغ به Pool هفته جاری
|
||||
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
||||
var weeklyPool = await _context.WeeklyCommissionPools
|
||||
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
|
||||
|
||||
if (weeklyPool == null)
|
||||
{
|
||||
weeklyPool = new WeeklyCommissionPool
|
||||
{
|
||||
WeekDefinitionId = currentWeekDefinitionId,
|
||||
TotalPoolAmount = activationFeeValue,
|
||||
TotalBalances = 0,
|
||||
ValuePerBalance = 0,
|
||||
IsCalculated = false,
|
||||
CalculatedAt = null
|
||||
};
|
||||
|
||||
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
|
||||
currentWeekDefinitionId,
|
||||
activationFeeValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
weeklyPool.TotalPoolAmount += activationFeeValue;
|
||||
_context.WeeklyCommissionPools.Update(weeklyPool);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
|
||||
activationFeeValue,
|
||||
currentWeekDefinitionId,
|
||||
weeklyPool.TotalPoolAmount
|
||||
);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 10. اعطای ویژگیهای باشگاه برای کاربر (فقط برای عضویت جدید)
|
||||
if (isNewMembership)
|
||||
{
|
||||
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
|
||||
var clubFeatures = await _context.ClubFeatures
|
||||
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (clubFeatures.Any())
|
||||
{
|
||||
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
|
||||
{
|
||||
UserId = user.Id,
|
||||
ClubMembershipId = clubMembership.Id,
|
||||
ClubFeatureId = feature.Id,
|
||||
GrantedAt = activationDate,
|
||||
IsActive = true,
|
||||
Notes = "اعطا شده هنگام امضای قرارداد"
|
||||
}).ToList();
|
||||
|
||||
_context.UserClubFeatures.AddRange(userClubFeatures);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Granted {Count} club features to UserId {UserId}",
|
||||
clubFeatures.Count,
|
||||
user.Id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}",
|
||||
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
|
||||
request.UserId,
|
||||
userContract.Id
|
||||
userContract.Id,
|
||||
clubMembership.Id
|
||||
);
|
||||
|
||||
return new AcceptClubMembershipContractResponseDto
|
||||
@@ -151,6 +289,19 @@ public class AcceptClubMembershipContractCommandHandler
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// دریافت شناسه تعریف هفته جاری
|
||||
/// </summary>
|
||||
private long GetCurrentWeekDefinitionId()
|
||||
{
|
||||
var week = _weekRepository.GetCurrentWeek();
|
||||
if (week == null)
|
||||
{
|
||||
throw new InvalidOperationException("هفته جاری در سیستم تعریف نشده است");
|
||||
}
|
||||
return week.Id;
|
||||
}
|
||||
|
||||
private async Task<(bool Success, string Message)> VerifyOtpAsync(
|
||||
string mobile,
|
||||
string code,
|
||||
|
||||
-4
@@ -4,10 +4,6 @@ public class AcceptClubMembershipContractCommandValidator : AbstractValidator<Ac
|
||||
{
|
||||
public AcceptClubMembershipContractCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("شناسه کاربر الزامی است");
|
||||
|
||||
RuleFor(x => x.OtpCode)
|
||||
.NotEmpty()
|
||||
.WithMessage("کد تایید الزامی است")
|
||||
|
||||
+12
-47
@@ -1,6 +1,7 @@
|
||||
using CMSMicroservice.Application.Common.Exceptions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.Common.Models;
|
||||
using CMSMicroservice.Domain.Common;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Entities.Club;
|
||||
using CMSMicroservice.Domain.Entities.Commission;
|
||||
@@ -75,7 +76,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
||||
}
|
||||
|
||||
if (wallet.Balance < 56_000_000)
|
||||
if (wallet.Balance < SystemConstants.BasePackageAmount)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"User {UserId} has insufficient balance: {Balance}",
|
||||
@@ -83,7 +84,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
wallet.Balance
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید"
|
||||
$"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,51 +136,14 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
var existingMembership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
|
||||
|
||||
// 6.1. دریافت مبلغ هدیه از تنظیمات
|
||||
var giftValueConfig = await _context.SystemConfigurations
|
||||
.FirstOrDefaultAsync(
|
||||
c => c.Key == "Club.MembershipGiftValue" && c.IsActive,
|
||||
cancellationToken
|
||||
);
|
||||
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از SystemConstants
|
||||
long giftValue = SystemConstants.ClubMembershipGiftValue;
|
||||
long activationFeeValue = SystemConstants.ClubActivationFee;
|
||||
|
||||
var activationFeeConfig = await _context.SystemConfigurations
|
||||
.FirstOrDefaultAsync(
|
||||
c => c.Key == "Club.ActivationFee" && c.IsActive,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
long giftValue = 28_000_000; // مقدار پیشفرض
|
||||
if (giftValueConfig != null && long.TryParse(giftValueConfig.Value, out var configValue))
|
||||
{
|
||||
giftValue = configValue;
|
||||
_logger.LogInformation(
|
||||
"Using Club.MembershipGiftValue from configuration: {GiftValue}",
|
||||
giftValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Club.MembershipGiftValue not found in configuration, using default: {GiftValue}",
|
||||
giftValue
|
||||
);
|
||||
}
|
||||
long activationFeeValue = 25_200_000; // مقدار پیشفرض
|
||||
if (activationFeeConfig != null && long.TryParse(activationFeeConfig.Value, out var activationFeeConfigValue))
|
||||
{
|
||||
activationFeeValue = activationFeeConfigValue;
|
||||
_logger.LogInformation(
|
||||
"Using Club.ActivationFee from configuration: {activationFeeValue}",
|
||||
activationFeeValue
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Club.ActivationFee not found in configuration, using default: {activationFeeValue}",
|
||||
activationFeeValue
|
||||
);
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
|
||||
giftValue, activationFeeValue
|
||||
);
|
||||
|
||||
ClubMembership entity;
|
||||
bool isNewMembership = existingMembership == null;
|
||||
@@ -297,8 +261,9 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
// 9. اضافه کردن ویژگیهای باشگاه برای کاربر (فقط برای عضویت جدید)
|
||||
if (isNewMembership)
|
||||
{
|
||||
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
|
||||
var clubFeatures = await _context.ClubFeatures
|
||||
.Where(f => !f.IsDeleted && new long[] { 1, 2, 3, 4 }.Contains(f.Id))
|
||||
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (clubFeatures.Any())
|
||||
|
||||
+25
-1
@@ -3,14 +3,18 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership
|
||||
public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQuery, ClubMembershipDto?>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<GetClubMembershipQueryHandler> _logger;
|
||||
|
||||
public GetClubMembershipQueryHandler(IApplicationDbContext context)
|
||||
public GetClubMembershipQueryHandler(IApplicationDbContext context, ILogger<GetClubMembershipQueryHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ClubMembershipDto?> Handle(GetClubMembershipQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("GetClubMembership called for UserId: {UserId}", request.UserId);
|
||||
|
||||
var membership = await _context.ClubMemberships
|
||||
.AsNoTracking()
|
||||
.Where(x => x.UserId == request.UserId)
|
||||
@@ -27,6 +31,26 @@ public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQu
|
||||
})
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-13
@@ -1,3 +1,5 @@
|
||||
using CMSMicroservice.Domain.Common;
|
||||
|
||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
|
||||
|
||||
public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWeeklyBalancesCommand, int>
|
||||
@@ -74,21 +76,11 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
||||
var balancesList = new List<NetworkWeeklyBalance>();
|
||||
var calculatedAt = DateTime.Now;
|
||||
|
||||
// خواندن یکباره Configuration ها (بهینهسازی - به جای N query)
|
||||
var configs = await _context.SystemConfigurations
|
||||
.Where(x => x.IsActive && (
|
||||
x.Key == "Club.ActivationFee" ||
|
||||
x.Key == "Commission.WeeklyPoolContributionPercent" ||
|
||||
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
|
||||
x.Key == "Commission.MaxNetworkLevel"))
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
|
||||
|
||||
// var activationFee = long.Parse(configs.GetValueOrDefault("Club.ActivationFee", "25000000"));
|
||||
// var poolPercent = decimal.Parse(configs.GetValueOrDefault("Commission.WeeklyPoolContributionPercent", "20")) / 100m;
|
||||
// استفاده از SystemConstants (استاتیک - بدون کوئری به دیتابیس)
|
||||
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
|
||||
var maxBalancesPerLeg = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerLeg", "300"));
|
||||
var maxBalancesPerLeg = SystemConstants.CommissionMaxWeeklyBalancesPerLeg;
|
||||
// حداکثر عمق شبکه برای شمارش اعضا (15 لول)
|
||||
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15"));
|
||||
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
|
||||
|
||||
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
|
||||
{
|
||||
|
||||
+4
-6
@@ -1,3 +1,5 @@
|
||||
using CMSMicroservice.Domain.Common;
|
||||
|
||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
|
||||
|
||||
public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayoutsCommand, int>
|
||||
@@ -47,12 +49,8 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// ⭐ خواندن MaxNetworkLevel از Config
|
||||
var maxNetworkLevelConfig = await _context.SystemConfigurations
|
||||
.Where(x => x.Key == "Commission.MaxNetworkLevel" && x.IsActive)
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var maxNetworkLevel = int.Parse(maxNetworkLevelConfig ?? "15");
|
||||
// ⭐ خواندن MaxNetworkLevel از SystemConstants (استاتیک)
|
||||
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
|
||||
|
||||
// دریافت همه تعادلهای هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
|
||||
var allWeeklyBalances = await _context.NetworkWeeklyBalances
|
||||
|
||||
+4
-4
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
|
||||
public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// از هفته (فیلتر اختیاری)
|
||||
/// از هفته — WeekDefinitionId (فیلتر اختیاری)
|
||||
/// </summary>
|
||||
public int? FromWeekOrder { get; init; }
|
||||
public long? FromWeekDefinitionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// تا هفته (فیلتر اختیاری)
|
||||
/// تا هفته — WeekDefinitionId (فیلتر اختیاری)
|
||||
/// </summary>
|
||||
public int? ToWeekOrder { get; init; }
|
||||
public long? ToWeekDefinitionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// فقط Pool های محاسبه شده
|
||||
|
||||
+5
-5
@@ -20,14 +20,14 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
|
||||
.AsNoTracking();
|
||||
|
||||
// Apply filters
|
||||
if (request.FromWeekOrder!=null)
|
||||
if (request.FromWeekDefinitionId != null)
|
||||
{
|
||||
query = query.Where(x => x.WeekDefinition.WeekOrder>=request.FromWeekOrder );
|
||||
query = query.Where(x => x.WeekDefinitionId >= request.FromWeekDefinitionId);
|
||||
}
|
||||
|
||||
if (request.ToWeekOrder!=null)
|
||||
if (request.ToWeekDefinitionId != null)
|
||||
{
|
||||
query = query.Where(x =>x.WeekDefinition.WeekOrder<= request.ToWeekOrder);
|
||||
query = query.Where(x => x.WeekDefinitionId <= request.ToWeekDefinitionId);
|
||||
}
|
||||
|
||||
if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
|
||||
@@ -49,7 +49,7 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
|
||||
{
|
||||
Id = x.Id,
|
||||
WeekDefinitionId = x.WeekDefinitionId,
|
||||
WeekDisplayName = x.WeekDefinition.PersianWeekNumber,
|
||||
WeekDisplayName = x.WeekDefinition.DisplayName,
|
||||
TotalPoolAmount = x.TotalPoolAmount,
|
||||
TotalBalances = x.TotalBalances,
|
||||
ValuePerBalance = x.ValuePerBalance,
|
||||
|
||||
+25
@@ -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; }
|
||||
}
|
||||
+73
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+24
@@ -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; }
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -21,10 +21,14 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
// فیلترها
|
||||
if (request.UserId.HasValue)
|
||||
// UserId > 0 → filter by that user
|
||||
// UserId == 0 or null → show ALL users (admin mode)
|
||||
// Customer endpoints resolve UserId from JWT before calling this handler
|
||||
long? userId = request.UserId;
|
||||
|
||||
if (userId.HasValue && userId.Value > 0)
|
||||
{
|
||||
query = query.Where(x => x.UserId == request.UserId.Value);
|
||||
query = query.Where(x => x.UserId == userId.Value);
|
||||
}
|
||||
|
||||
if (request.Status.HasValue)
|
||||
|
||||
+15
-5
@@ -17,13 +17,18 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
|
||||
{
|
||||
var query = _context.NetworkWeeklyBalances
|
||||
.Include(x => x.WeekDefinition)
|
||||
.Include(x => x.User)
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
// فیلترها
|
||||
if (request.UserId.HasValue)
|
||||
// UserId > 0 → filter by that user
|
||||
// UserId == 0 or null → show ALL users (admin mode)
|
||||
// Customer endpoints resolve UserId from JWT before calling this handler
|
||||
long? userId = request.UserId;
|
||||
|
||||
if (userId.HasValue && userId.Value > 0)
|
||||
{
|
||||
query = query.Where(x => x.UserId == request.UserId.Value);
|
||||
query = query.Where(x => x.UserId == userId.Value);
|
||||
}
|
||||
|
||||
// فیلتر بر اساس WeekDefinitionId (روش ترجیحی)
|
||||
@@ -49,10 +54,15 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
|
||||
{
|
||||
Id = x.Id,
|
||||
UserId = x.UserId,
|
||||
UserFullName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}".Trim() : "",
|
||||
WeekDefinitionId = x.WeekDefinitionId,
|
||||
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
|
||||
LeftLegBalances = x.LeftLegBalances,
|
||||
RightLegBalances = x.RightLegBalances,
|
||||
LeftLegNewMembers = x.LeftLegNewMembers,
|
||||
LeftLegCarryover = x.LeftLegCarryover,
|
||||
LeftLegTotal = x.LeftLegTotal,
|
||||
RightLegNewMembers = x.RightLegNewMembers,
|
||||
RightLegCarryover = x.RightLegCarryover,
|
||||
RightLegTotal = x.RightLegTotal,
|
||||
TotalBalances = x.TotalBalances,
|
||||
WeeklyPoolContribution = x.WeeklyPoolContribution,
|
||||
CalculatedAt = x.CalculatedAt,
|
||||
|
||||
+12
-3
@@ -10,11 +10,20 @@ public class GetUserWeeklyBalancesResponseModel
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string UserFullName { get; set; } = string.Empty;
|
||||
public long WeekDefinitionId { get; set; }
|
||||
|
||||
public string WeekDisplayName { get; set; } = string.Empty;
|
||||
public int LeftLegBalances { get; set; }
|
||||
public int RightLegBalances { get; set; }
|
||||
|
||||
// چپ
|
||||
public int LeftLegNewMembers { get; set; }
|
||||
public int LeftLegCarryover { get; set; }
|
||||
public int LeftLegTotal { get; set; }
|
||||
|
||||
// راست
|
||||
public int RightLegNewMembers { get; set; }
|
||||
public int RightLegCarryover { get; set; }
|
||||
public int RightLegTotal { get; set; }
|
||||
|
||||
public int TotalBalances { get; set; }
|
||||
public long WeeklyPoolContribution { get; set; }
|
||||
public DateTime? CalculatedAt { get; set; }
|
||||
|
||||
+1
@@ -107,6 +107,7 @@ public class GetWeekDefinitionsQueryHandler : IRequestHandler<GetWeekDefinitions
|
||||
// تبدیل به DTO
|
||||
var weekDtos = weeksList.Select(w => new WeekDefinitionItemDto
|
||||
{
|
||||
Id = w.Id,
|
||||
WeekOrder = w.WeekOrder,
|
||||
DisplayName = w.DisplayName,
|
||||
GregorianWeekNumber = w.GregorianWeekNumber,
|
||||
|
||||
+3
@@ -17,6 +17,9 @@ public class GetWeekDefinitionsResponseDto
|
||||
/// </summary>
|
||||
public class WeekDefinitionItemDto
|
||||
{
|
||||
//شناسه هفته
|
||||
public long Id { get; set; }
|
||||
|
||||
//شماره ترتیب هفته (1, 2, 3, ...)
|
||||
public int WeekOrder { get; set; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -18,7 +18,10 @@ public class LoggingBehaviour<TRequest> : IRequestPreProcessor<TRequest> where T
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
var userId = _currentUserService.UserId ?? string.Empty;
|
||||
_logger.LogInformation("Request: {Name} {@UserId} {@Request}",
|
||||
requestName, userId, request);
|
||||
var safeLog = request?.ToString() ?? "";
|
||||
if (safeLog.Length > 2000)
|
||||
safeLog = safeLog[..2000] + "... [TRUNCATED]";
|
||||
_logger.LogInformation("Request: {Name} {UserId} {Request}",
|
||||
requestName, userId, safeLog);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.Common.Behaviours;
|
||||
|
||||
public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
public partial class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly Stopwatch _timer;
|
||||
private readonly ILogger<TRequest> _logger;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
[GeneratedRegex(@"ImageFile(?:Bytes|Mime|FileName)|ImageFile|File", RegexOptions.None)]
|
||||
private static partial Regex BinaryPropertyPattern();
|
||||
|
||||
public PerformanceBehaviour(ILogger<TRequest> logger, ICurrentUserService currentUserService)
|
||||
{
|
||||
_timer = new Stopwatch();
|
||||
@@ -33,11 +37,20 @@ public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequ
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
var userId = _currentUserService.UserId ?? string.Empty;
|
||||
var safeLog = SanitizeForLog(request);
|
||||
|
||||
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@Request}",
|
||||
requestName, elapsedMilliseconds, userId, request);
|
||||
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {UserId} {Request}",
|
||||
requestName, elapsedMilliseconds, userId, safeLog);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string SanitizeForLog(TRequest request)
|
||||
{
|
||||
var text = request?.ToString() ?? "";
|
||||
if (text.Length > 2000)
|
||||
return text[..2000] + "... [TRUNCATED]";
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,11 @@ public class UnhandledExceptionBehaviour<TRequest, TResponse> : IPipelineBehavio
|
||||
catch (Exception ex)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
var safeLog = request?.ToString() ?? "";
|
||||
if (safeLog.Length > 2000)
|
||||
safeLog = safeLog[..2000] + "... [TRUNCATED]";
|
||||
|
||||
_logger.LogError(ex, "Request: Unhandled Exception for Request {Name} {@Request}", requestName, request);
|
||||
_logger.LogError(ex, "Request: Unhandled Exception for Request {Name} {Request}", requestName, safeLog);
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace CMSMicroservice.Application.Common.FileManager;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس مدیریت فایل — ذخیره روی دیسک، مسیر نسبی در دیتابیس
|
||||
/// موقع واکشی: خواندن از دیسک و تبدیل به base64 data-URI
|
||||
/// </summary>
|
||||
public interface IFileManager
|
||||
{
|
||||
/// <summary>
|
||||
/// آپلود یک فایل خام به دیسک
|
||||
/// </summary>
|
||||
/// <returns>نتیجه آپلود شامل مسیر نسبی فایل</returns>
|
||||
/// <exception cref="FileUploadException">در صورت خطای آپلود</exception>
|
||||
Task<UploadedFile> UploadAsync(
|
||||
string directory,
|
||||
byte[] fileBytes,
|
||||
string mime,
|
||||
string? fileName = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// آپلود تصویر با بهینهسازی خودکار + ساخت بندانگشتی
|
||||
/// </summary>
|
||||
/// <returns>نتیجه آپلود شامل تصویر اصلی و بندانگشتی (مسیرهای نسبی)</returns>
|
||||
/// <exception cref="FileUploadException">در صورت خطای آپلود</exception>
|
||||
Task<UploadedImage> UploadImageAsync(
|
||||
string directory,
|
||||
byte[] fileBytes,
|
||||
string mime,
|
||||
string? fileName = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// حذف فایل از دیسک
|
||||
/// </summary>
|
||||
Task DeleteAsync(long fileId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// خواندن فایل از دیسک و تبدیل به base64 data-URI
|
||||
/// اگر مسیر از قبل data: باشد، همان را برمیگرداند
|
||||
/// اگر فایل وجود نداشته باشد، رشته خالی برمیگرداند
|
||||
/// </summary>
|
||||
string ResolveImageUrl(string? path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// نتیجه آپلود فایل
|
||||
/// </summary>
|
||||
/// <param name="Id">شناسه فایل در FMS</param>
|
||||
/// <param name="Path">مسیر فایل ذخیرهشده (مثلاً /Images/Products/abc.jpg)</param>
|
||||
public sealed record UploadedFile(long Id, string Path);
|
||||
|
||||
/// <summary>
|
||||
/// نتیجه آپلود تصویر — شامل تصویر اصلی و بندانگشتی
|
||||
/// </summary>
|
||||
/// <param name="Main">تصویر اصلی بهینهشده</param>
|
||||
/// <param name="Thumbnail">تصویر بندانگشتی</param>
|
||||
public sealed record UploadedImage(UploadedFile Main, UploadedFile Thumbnail);
|
||||
|
||||
/// <summary>
|
||||
/// خطای آپلود فایل
|
||||
/// </summary>
|
||||
public class FileUploadException : Exception
|
||||
{
|
||||
public FileUploadException(string message) : base(message) { }
|
||||
public FileUploadException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using CMSMicroservice.Domain.Entities.Blog;
|
||||
using CMSMicroservice.Domain.Entities.Content;
|
||||
using CMSMicroservice.Domain.Entities.Payment;
|
||||
using CMSMicroservice.Domain.Entities.Order;
|
||||
using CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
using CMSMicroservice.Domain.Entities.Geography;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
@@ -30,9 +33,8 @@ public interface IApplicationDbContext
|
||||
DbSet<UserPackagePurchase> UserPackagePurchases { get; }
|
||||
DbSet<UserWallet> UserWallets { get; }
|
||||
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
|
||||
DbSet<SystemConfiguration> SystemConfigurations { get; }
|
||||
DbSet<SystemConfigurationHistory> SystemConfigurationHistories { get; }
|
||||
DbSet<ManualPayment> ManualPayments { get; }
|
||||
DbSet<PaymentTransaction> PaymentTransactions { get; }
|
||||
DbSet<PublicMessage> PublicMessages { get; }
|
||||
DbSet<ClubMembership> ClubMemberships { get; }
|
||||
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
|
||||
@@ -46,11 +48,13 @@ public interface IApplicationDbContext
|
||||
DbSet<CommissionPayoutHistory> CommissionPayoutHistories { get; }
|
||||
DbSet<WorkerExecutionLog> WorkerExecutionLogs { get; }
|
||||
DbSet<DayaLoanContract> DayaLoanContracts { get; }
|
||||
DbSet<AppVersion> AppVersions { get; }
|
||||
|
||||
// ============= Discount Shop =============
|
||||
DbSet<DiscountProduct> DiscountProducts { get; }
|
||||
DbSet<DiscountCategory> DiscountCategories { get; }
|
||||
DbSet<DiscountProductCategory> DiscountProductCategories { get; }
|
||||
DbSet<DiscountProductImage> DiscountProductImages { get; }
|
||||
DbSet<DiscountShoppingCart> DiscountShoppingCarts { get; }
|
||||
DbSet<DiscountOrder> DiscountOrders { get; }
|
||||
DbSet<DiscountOrderDetail> DiscountOrderDetails { get; }
|
||||
@@ -60,5 +64,26 @@ public interface IApplicationDbContext
|
||||
DbSet<State> States { get; }
|
||||
DbSet<City> Cities { get; }
|
||||
|
||||
// ============= Inventory Management =============
|
||||
DbSet<Warehouse> Warehouses { get; }
|
||||
DbSet<InventoryItem> InventoryItems { get; }
|
||||
DbSet<StockMovement> StockMovements { get; }
|
||||
|
||||
// ============= Blog =============
|
||||
DbSet<BlogPost> BlogPosts { get; }
|
||||
DbSet<BlogCategory> BlogCategories { get; }
|
||||
DbSet<BlogPostCategory> BlogPostCategories { get; }
|
||||
DbSet<BlogPostTag> BlogPostTags { get; }
|
||||
DbSet<BlogPostImage> BlogPostImages { get; }
|
||||
|
||||
// ============= Content Management =============
|
||||
DbSet<SitePage> SitePages { get; }
|
||||
DbSet<SitePageSection> SitePageSections { get; }
|
||||
|
||||
/// <summary>
|
||||
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
|
||||
/// </summary>
|
||||
DatabaseFacade Database { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس ارتباط با API چتیکا برای ساخت حساب کاربری
|
||||
/// </summary>
|
||||
public interface IChatikaApiService
|
||||
{
|
||||
/// <summary>
|
||||
/// ایجاد حساب کاربری در چتیکا
|
||||
/// </summary>
|
||||
/// <param name="mobileNumber">شماره موبایل کاربر</param>
|
||||
/// <param name="fullName">نام کامل کاربر</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>نتیجه ایجاد حساب</returns>
|
||||
Task<ChatikaAccountResult> CreateAccountAsync(
|
||||
string mobileNumber,
|
||||
string fullName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// نتیجه ایجاد حساب در چتیکا
|
||||
/// </summary>
|
||||
public class ChatikaAccountResult
|
||||
{
|
||||
/// <summary>
|
||||
/// موفقیت عملیات
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// پیام خطا در صورت عدم موفقیت
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه کاربر در چتیکا
|
||||
/// </summary>
|
||||
public string? ChatikaUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL دسترسی به چتیکا
|
||||
/// </summary>
|
||||
public string? AccessUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ایجاد نتیجه موفق
|
||||
/// </summary>
|
||||
public static ChatikaAccountResult Success(string? chatikaUserId = null, string? accessUrl = null) => new()
|
||||
{
|
||||
IsSuccess = true,
|
||||
ChatikaUserId = chatikaUserId,
|
||||
AccessUrl = accessUrl
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// ایجاد نتیجه ناموفق
|
||||
/// </summary>
|
||||
public static ChatikaAccountResult Failure(string errorMessage) => new()
|
||||
{
|
||||
IsSuccess = false,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس مدیریت موجودی - لایه بالاتر برای عملیات business
|
||||
/// این سرویس مسئول همگامسازی موجودی بین InventoryItem و Product.RemainingCount است
|
||||
/// </summary>
|
||||
public interface IInventoryService
|
||||
{
|
||||
#region Initialization
|
||||
|
||||
/// <summary>
|
||||
/// ایجاد رکورد موجودی برای محصول جدید
|
||||
/// این متد باید در CreateProductCommandHandler و CreateDiscountProductCommandHandler فراخوانی شود
|
||||
/// </summary>
|
||||
/// <param name="productId">شناسه محصول (Product.Id یا DiscountProduct.Id)</param>
|
||||
/// <param name="productType">نوع محصول (RegularProduct یا DiscountProduct)</param>
|
||||
/// <param name="initialQuantity">موجودی اولیه</param>
|
||||
/// <param name="warehouseId">شناسه انبار (پیشفرض: انبار اصلی)</param>
|
||||
/// <param name="lowStockThreshold">آستانه هشدار کمموجودی</param>
|
||||
/// <param name="ct">CancellationToken</param>
|
||||
/// <returns>شناسه InventoryItem ایجاد شده</returns>
|
||||
Task<long> InitializeInventoryAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int initialQuantity,
|
||||
long? warehouseId = null,
|
||||
int lowStockThreshold = 10,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Operations
|
||||
|
||||
/// <summary>
|
||||
/// دریافت موجودی یک محصول
|
||||
/// </summary>
|
||||
Task<InventoryItem?> GetInventoryAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
long? warehouseId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// دریافت موجودی قابل فروش (Quantity - ReservedQuantity)
|
||||
/// </summary>
|
||||
Task<int> GetAvailableQuantityAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
long? warehouseId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// بررسی اینکه آیا موجودی کافی برای فروش وجود دارد
|
||||
/// </summary>
|
||||
Task<bool> CheckAvailabilityAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int requiredQuantity,
|
||||
long? warehouseId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// دریافت لیست محصولات کمموجود
|
||||
/// </summary>
|
||||
Task<List<InventoryItem>> GetLowStockItemsAsync(
|
||||
ProductType? productType = null,
|
||||
long? warehouseId = null,
|
||||
int count = 50,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// دریافت تاریخچه حرکات موجودی
|
||||
/// </summary>
|
||||
Task<List<StockMovement>> GetStockMovementsAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
DateTime? fromDate = null,
|
||||
DateTime? toDate = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Order Flow Operations
|
||||
|
||||
/// <summary>
|
||||
/// رزرو موجودی برای سفارش pending
|
||||
/// این متد در PlaceOrderCommandHandler فراخوانی میشود
|
||||
/// فقط ReservedQuantity را افزایش میدهد، Quantity تغییر نمیکند
|
||||
/// </summary>
|
||||
/// <param name="productId">شناسه محصول</param>
|
||||
/// <param name="productType">نوع محصول</param>
|
||||
/// <param name="quantity">تعداد رزرو</param>
|
||||
/// <param name="orderId">شناسه سفارش (Order.Id یا DiscountOrder.Id)</param>
|
||||
/// <param name="ct">CancellationToken</param>
|
||||
/// <returns>true اگر رزرو موفق بود</returns>
|
||||
Task<bool> ReserveStockAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int quantity,
|
||||
long? orderId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// آزادسازی رزرو (لغو سفارش یا timeout)
|
||||
/// این متد در CancelOrderCommandHandler فراخوانی میشود
|
||||
/// </summary>
|
||||
Task<bool> ReleaseReservationAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int quantity,
|
||||
long? orderId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// تایید فروش - کسر واقعی موجودی
|
||||
/// این متد در CompleteOrderPaymentCommandHandler فراخوانی میشود
|
||||
/// ReservedQuantity کاهش مییابد، Quantity کاهش مییابد، Product.RemainingCount sync میشود
|
||||
/// </summary>
|
||||
Task<bool> ConfirmSaleAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int quantity,
|
||||
long? orderId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stock Management Operations
|
||||
|
||||
/// <summary>
|
||||
/// ورود کالا به انبار (Restock)
|
||||
/// </summary>
|
||||
/// <param name="productId">شناسه محصول</param>
|
||||
/// <param name="productType">نوع محصول</param>
|
||||
/// <param name="quantity">تعداد ورودی</param>
|
||||
/// <param name="referenceNumber">شماره مرجع (مثل شماره فاکتور خرید)</param>
|
||||
/// <param name="note">یادداشت</param>
|
||||
/// <param name="performedByUserId">شناسه کاربر انجامدهنده</param>
|
||||
/// <param name="ct">CancellationToken</param>
|
||||
Task<bool> AddStockAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int quantity,
|
||||
string? referenceNumber = null,
|
||||
string? note = null,
|
||||
long? performedByUserId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// تعدیل موجودی (تنظیم به مقدار جدید)
|
||||
/// </summary>
|
||||
Task<bool> AdjustStockAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int newQuantity,
|
||||
string? note = null,
|
||||
long? performedByUserId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// ثبت برگشت کالا از مشتری
|
||||
/// </summary>
|
||||
Task<bool> ProcessReturnAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int quantity,
|
||||
long? orderId = null,
|
||||
string? note = null,
|
||||
long? performedByUserId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// ثبت ضایعات/مفقودی
|
||||
/// </summary>
|
||||
Task<bool> RecordLossAsync(
|
||||
long productId,
|
||||
ProductType productType,
|
||||
int quantity,
|
||||
StockMovementType lossType, // Damaged or Lost
|
||||
string? note = null,
|
||||
long? performedByUserId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bulk Operations
|
||||
|
||||
/// <summary>
|
||||
/// رزرو موجودی برای چند آیتم (یک سفارش با چند محصول)
|
||||
/// </summary>
|
||||
Task<bool> BulkReserveStockAsync(
|
||||
IEnumerable<(long ProductId, ProductType ProductType, int Quantity)> items,
|
||||
long? orderId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// آزادسازی رزرو برای چند آیتم
|
||||
/// </summary>
|
||||
Task<bool> BulkReleaseReservationAsync(
|
||||
IEnumerable<(long ProductId, ProductType ProductType, int Quantity)> items,
|
||||
long? orderId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// تایید فروش برای چند آیتم
|
||||
/// </summary>
|
||||
Task<bool> BulkConfirmSaleAsync(
|
||||
IEnumerable<(long ProductId, ProductType ProductType, int Quantity)> items,
|
||||
long? orderId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس ارسال SMS با کاوهنگار
|
||||
/// </summary>
|
||||
public interface IKavenegarService
|
||||
{
|
||||
/// <summary>
|
||||
/// ارسال پیامک ساده
|
||||
/// </summary>
|
||||
Task SendAsync(string mobile, string message);
|
||||
|
||||
/// <summary>
|
||||
/// ارسال پیامک با قالب (VerifyLookup)
|
||||
/// </summary>
|
||||
Task VerifyLookupAsync(string mobile, string token, string template = "Afrino");
|
||||
}
|
||||
@@ -27,6 +27,24 @@ public interface IPaymentGatewayService
|
||||
string verificationToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// تأیید پرداخت با مبلغ — برای درگاههایی مثل زرینپال که مبلغ را در Verify نیاز دارند
|
||||
/// </summary>
|
||||
/// <param name="refId">شماره مرجع تراکنش (Authority در زرینپال)</param>
|
||||
/// <param name="verificationToken">توکن تأیید از درگاه (Status در زرینپال)</param>
|
||||
/// <param name="amountInToman">مبلغ تراکنش به تومان</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>وضعیت نهایی تراکنش</returns>
|
||||
Task<PaymentVerificationResult> VerifyPaymentAsync(
|
||||
string refId,
|
||||
string verificationToken,
|
||||
decimal amountInToman,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// پیشفرض: درگاههایی که Amount نمیخواهند، از overload بدون amount استفاده کنند
|
||||
return VerifyPaymentAsync(refId, verificationToken, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
|
||||
/// </summary>
|
||||
@@ -124,6 +142,21 @@ public class PaymentVerificationResult
|
||||
/// پیام
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره کارت ماسکشده (مثلاً 6037-****-****-1234)
|
||||
/// </summary>
|
||||
public string? CardPan { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// هش کارت بانکی
|
||||
/// </summary>
|
||||
public string? CardHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// کد وضعیت verify از درگاه (100=موفق، 101=تکراری)
|
||||
/// </summary>
|
||||
public int? VerificationCode { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
using CMSMicroservice.Application.UserCartsCQ.Queries.GetAllUserCartsByFilter;
|
||||
|
||||
namespace CMSMicroservice.Application.Common.Mappings;
|
||||
|
||||
public class UserCartsProfile : IRegister
|
||||
{
|
||||
void IRegister.Register(TypeAdapterConfig config)
|
||||
{
|
||||
config.NewConfig<UserCart,GetAllUserCartsByFilterResponseModel>()
|
||||
.Map(dest => dest.Id, src => src.Id)
|
||||
.Map(dest => dest.Count, src => src.Count)
|
||||
.Map(dest => dest.ProductId, src => src.ProductId)
|
||||
.Map(dest => dest.ProductTitle, src => src.Product.Title)
|
||||
.Map(dest => dest.ProductShortInfomation, src => src.Product.ShortInfomation)
|
||||
.Map(dest => dest.ProductDiscount, src => src.Product.Discount)
|
||||
.Map(dest => dest.ProductPrice, src => src.Product.Price)
|
||||
.Map(dest => dest.ProductThumbnailPath, src => src.Product.ThumbnailPath)
|
||||
.Map(dest => dest.Created, src => src.Created)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using CMSMicroservice.Application.UserOrderCQ.Queries.GetAllUserOrderByFilter;
|
||||
using CMSMicroservice.Application.UserOrderCQ.Queries.GetUserOrder;
|
||||
|
||||
namespace CMSMicroservice.Application.Common.Mappings;
|
||||
|
||||
public class UserOrderProfile : IRegister
|
||||
{
|
||||
void IRegister.Register(TypeAdapterConfig config)
|
||||
{
|
||||
config.NewConfig<UserOrder,GetUserOrderResponseDto>()
|
||||
.Map(dest => dest.Id, src => src.Id)
|
||||
.Map(dest => dest.Amount, src => src.Amount)
|
||||
.Map(dest => dest.PackageId, src => src.PackageId)
|
||||
.Map(dest => dest.TransactionId, src => src.TransactionId)
|
||||
.Map(dest => dest.PaymentStatus, src => src.PaymentStatus)
|
||||
.Map(dest => dest.PaymentDate, src => src.PaymentDate)
|
||||
.Map(dest => dest.UserId, src => src.UserId)
|
||||
.Map(dest => dest.UserAddressId, src => src.UserAddressId)
|
||||
.Map(dest => dest.PaymentMethod, src => src.PaymentMethod)
|
||||
.Map(dest => dest.UserAddressText, src => src.UserAddress.Address)
|
||||
.Map(dest => dest.FactorDetails, src => src.FactorDetails.Select(s=>s.Adapt<GetUserOrderResponseFactorDetail>()))
|
||||
|
||||
;
|
||||
|
||||
config.NewConfig<UserOrder,GetAllUserOrderByFilterResponseModel>()
|
||||
.Map(dest => dest.Id, src => src.Id)
|
||||
.Map(dest => dest.Amount, src => src.Amount)
|
||||
.Map(dest => dest.PackageId, src => src.PackageId)
|
||||
.Map(dest => dest.TransactionId, src => src.TransactionId)
|
||||
.Map(dest => dest.PaymentStatus, src => src.PaymentStatus)
|
||||
.Map(dest => dest.PaymentDate, src => src.PaymentDate)
|
||||
.Map(dest => dest.UserId, src => src.UserId)
|
||||
.Map(dest => dest.UserAddressId, src => src.UserAddressId)
|
||||
.Map(dest => dest.PaymentMethod, src => src.PaymentMethod)
|
||||
.Map(dest => dest.UserAddressText, src => src.UserAddress.Address)
|
||||
.Map(dest => dest.FactorDetails, src => src.FactorDetails.Select(s=>s.Adapt<GetUserOrderResponseFactorDetail>()))
|
||||
;
|
||||
|
||||
config.NewConfig<FactorDetails,GetUserOrderResponseFactorDetail>()
|
||||
.Map(dest => dest.ProductId, src => src.ProductId)
|
||||
.Map(dest => dest.ProductTitle, src => src.Product.Title)
|
||||
.Map(dest => dest.ProductThumbnailPath, src => src.Product.ThumbnailPath)
|
||||
.Map(dest => dest.UnitPrice, src => src.Product.Price)
|
||||
.Map(dest => dest.Count, src => src.Count)
|
||||
.Map(dest => dest.UnitDiscountPrice, src => src.Product.Price*(src.Product.Discount/100))
|
||||
;
|
||||
|
||||
config.NewConfig<FactorDetails,GetAllUserOrderByFilterResponseModelFactorDetail>()
|
||||
.Map(dest => dest.ProductId, src => src.ProductId)
|
||||
.Map(dest => dest.ProductTitle, src => src.Product.Title)
|
||||
.Map(dest => dest.ProductThumbnailPath, src => src.Product.ThumbnailPath)
|
||||
.Map(dest => dest.UnitPrice, src => src.Product.Price)
|
||||
.Map(dest => dest.Count, src => src.Count)
|
||||
.Map(dest => dest.UnitDiscountPrice, src => src.Product.Price*(src.Product.Discount/100))
|
||||
;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace CMSMicroservice.Application.Common.Services;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس محاسبه مالیات بر ارزش افزوده (VAT)
|
||||
/// </summary>
|
||||
public static class VatCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// نرخ VAT ایران - 9 درصد
|
||||
/// </summary>
|
||||
public const decimal VAT_RATE = 0.09m;
|
||||
|
||||
/// <summary>
|
||||
/// نرخ VAT به صورت درصد (9)
|
||||
/// </summary>
|
||||
public const int VAT_PERCENT = 9;
|
||||
|
||||
/// <summary>
|
||||
/// محاسبه VAT از مبلغ خالص
|
||||
/// </summary>
|
||||
/// <param name="netAmount">مبلغ خالص (بدون مالیات)</param>
|
||||
/// <returns>مبلغ VAT</returns>
|
||||
public static long CalculateVat(long netAmount)
|
||||
{
|
||||
return (long)(netAmount * VAT_RATE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// محاسبه مبلغ ناخالص (شامل VAT) از مبلغ خالص
|
||||
/// </summary>
|
||||
/// <param name="netAmount">مبلغ خالص</param>
|
||||
/// <returns>مبلغ ناخالص (خالص + VAT)</returns>
|
||||
public static long CalculateGrossAmount(long netAmount)
|
||||
{
|
||||
return netAmount + CalculateVat(netAmount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// استخراج مبلغ خالص از مبلغ ناخالص
|
||||
/// </summary>
|
||||
/// <param name="grossAmount">مبلغ ناخالص (شامل VAT)</param>
|
||||
/// <returns>مبلغ خالص</returns>
|
||||
public static long ExtractNetAmount(long grossAmount)
|
||||
{
|
||||
return (long)(grossAmount / (1 + VAT_RATE));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// استخراج VAT از مبلغ ناخالص
|
||||
/// </summary>
|
||||
/// <param name="grossAmount">مبلغ ناخالص (شامل VAT)</param>
|
||||
/// <returns>مبلغ VAT</returns>
|
||||
public static long ExtractVatFromGross(long grossAmount)
|
||||
{
|
||||
return grossAmount - ExtractNetAmount(grossAmount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// جزئیات محاسبه VAT
|
||||
/// </summary>
|
||||
public record VatBreakdown(
|
||||
long NetAmount,
|
||||
long VatAmount,
|
||||
long GrossAmount,
|
||||
decimal VatRate
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// محاسبه کامل جزئیات VAT
|
||||
/// </summary>
|
||||
/// <param name="netAmount">مبلغ خالص</param>
|
||||
/// <returns>جزئیات کامل VAT</returns>
|
||||
public static VatBreakdown CalculateBreakdown(long netAmount)
|
||||
{
|
||||
var vatAmount = CalculateVat(netAmount);
|
||||
return new VatBreakdown(
|
||||
NetAmount: netAmount,
|
||||
VatAmount: vatAmount,
|
||||
GrossAmount: netAmount + vatAmount,
|
||||
VatRate: VAT_RATE
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user