48 Commits

Author SHA1 Message Date
masoodafar-web e72673c184 chore(staging): remove appsettings.Production.json — config comes from K8s Secret
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m42s
DO NOT cherry-pick this commit to production branch
2026-02-23 22:07:58 +03:30
masoodafar-web 9288d0640b feat: externalize appsettings to K8s Secret — config persists independently of image rebuilds
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m57s
- Add cms-config.yaml (K8s Secret) for staging and production
- Mount appsettings.{Environment}.json from Secret into /app/
- Update CI/CD pipelines to apply Secret before Deployment
- Remove redundant env vars (Kestrel, FileStorage) — now in config file
2026-02-23 21:58:05 +03:30
masoodafar-web de83c31346 fix: production uses namespace default + remove foursat namespace references
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m18s
2026-02-23 21:36:08 +03:30
masoodafar-web f8dc4abd04 fix: staging ASPNETCORE_ENVIRONMENT=Staging, remove secretKeyRef (config in appsettings)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m28s
2026-02-23 21:17:23 +03:30
masoodafar-web 2d6c95e6ee fix: use local registry 194.5.195.53:30080 instead of git.se.kbs1.ir for image pull
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11m28s
2026-02-23 20:58:16 +03:30
masoodafar-web e41747afe0 fix: production ingress — add cms.kbs2.ir, use ingressClassName instead of annotation
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 12m6s
2026-02-23 20:41:12 +03:30
masoodafar-web 68da3f45a3 fix: staging uses namespace default, not foursat
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-23 20:38:27 +03:30
masoodafar-web 3153fd8a74 feat: add PersistentVolume for CMS uploads + apply manifests in CI/CD
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 12m12s
- Add k8s/staging/ and k8s/production/ deployment manifests
- PVC 20Gi for /app/Uploads (file uploads persist across pod restarts)
- FileStorage__UploadPath env var set to /app/Uploads
- Replicas reduced to 1 (ReadWriteOnce PVC)
- Pipelines now: create namespace → apply manifests → restart/update
2026-02-23 20:11:56 +03:30
masoodafar-web 8cdb197ee7 chore: prevent appsettings.Production.json from merging — keep ours strategy
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m15s
2026-02-23 00:01:56 +03:30
masoodafar-web 2ca3fd29f9 feat: admin force activation — skip order/wallet checks for BackOffice
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m12s
- Proto: add force_activation field to ActivateClubMembershipRequest
- Command: add ForceActivation property
- Handler: wrap steps 2-5 in ForceActivation guard
  - Skip PackagePurchaseMethod check (set to DirectPurchase if None)
  - Skip wallet balance check
  - Skip Magic wallet mode check
  - Skip UserOrder/Transaction validation
- Proto NuGet bumped to 0.0.184
2026-02-22 23:51:39 +03:30
masoodafar-web b9a72b4589 fix: environment-specific gateway config — staging=sandbox, production=real
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m37s
- appsettings.json/Staging.json: UseSandbox=true (sandbox mode for testing)
- appsettings.Production.json: UseSandbox=false, MerchantId=new production key
- Added SeedWorkers, Kestrel, Seq overrides to Production config
2026-02-22 22:18:51 +03:30
masoodafar-web 5c63dc6e38 feat: activate ZarinPal gateway with production MerchantId
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- MerchantId: 4225d555-5fa9-4df0-9b61-1ce152cbbba8
- UseSandbox: false (production mode)
2026-02-22 22:12:02 +03:30
masoodafar-web 52f6af2091 feat: add user_name to wallet response + enrich via Users table
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m59s
- userwallet.proto: added string user_name = 5 to GetAllUserWalletByFilterResponseModel
- UserWalletService: enriches response with user names from Users table
- Proto version bumped to 0.0.183
2026-02-22 22:02:54 +03:30
masoodafar-web e3fbd45739 fix: expand DiscountProducts.FullInformation from nvarchar(2000) to nvarchar(max) — HTML content was getting truncated
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m17s
2026-02-22 21:44:03 +03:30
masoodafar-web 0fe4db5e77 fix: StockMovement CHECK constraint violation — store Quantity as negative for reduce operations, pass correct MovementType from RecordLoss
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-22 21:38:20 +03:30
masoodafar-web 305b8d3fd8 feat: add MagicWalletCycleSeedService — one-time BackgroundService to seed ClubMembershipCycle for existing active memberships, controlled via appsettings SeedWorkers:MagicWalletCycleSeed:Enabled
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m36s
2026-02-22 21:14:09 +03:30
masoodafar-web df976b7b80 fix: Update project version to 0.0.182
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m5s
2026-02-22 20:48:26 +03:30
masoodafar-web b90aa50fac feat: ChargeDiscountWallet - callback endpoint, gRPC RPC & service implementation
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m43s
- Add /api/wallet/verify-discount-charge callback endpoint in PaymentCallbackController
- Add InitiateDiscountCharge RPC + request/response messages in userwallet.proto
- Add InitiateDiscountCharge gRPC service override in UserWalletService
- Add ChargeDiscountWallet using import for handler resolution
2026-02-22 20:22:44 +03:30
masoodafar-web 58d419ced3 feat: Magic Wallet Phase 6 + Daya restriction + purchase cycle count
- ActivateClubMembershipCommandHandler: block activation in Magic mode
- CheckAndProcessDayaLoansCommandHandler: reject Daya after first cycle
- userwallet.proto: add purchase_cycle_count to GetMagicWalletStatusResponse
- UserWalletService: query ClubMembershipCycles count for cycle info
- EF Migration u21: ClubMembershipCycle table + UserWallet Magic fields
2026-02-22 20:04:13 +03:30
masoodafar-web bdd2c51726 fix: Update project version to 0.0.181 and adjust NuGet package path
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-21 20:37:14 +03:30
masoodafar-web 2a569a024f feat: Implement Magic Wallet functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m36s
- Added ClubMembershipCycle entity and DbSet to IApplicationDbContext.
- Updated VAT rate from 9% to 10% in VatCalculator and related areas.
- Introduced Magic Wallet settings in SystemConstants.
- Enhanced UserWallet entity to support Magic Wallet features.
- Updated TransactionType enum to include Magic Wallet transactions.
- Configured UserWallet to handle Magic Wallet properties in ApplicationDbContext.
- Implemented OrmCommissionCalculationStrategy to exclude Magic Wallet users from commission calculations.
- Added Protobuf definitions for Magic Wallet methods and responses.
- Created PaymentCallbackController endpoint for handling Magic Wallet charge callbacks.
- Updated UserOrderService to manage Magic Wallet state transitions.
- Developed UserWalletService to support Magic Wallet operations.
- Created ChargeMagicWalletCommand and its handler for initiating Magic Wallet charges.
- Implemented VerifyMagicWalletChargeCommand and handler for payment verification.
- Added validation for ChargeMagicWalletCommand.
- Established ClubMembershipCycle configuration for EF Core.
- Introduced WalletMode enum to differentiate between Normal and Magic modes.
2026-02-21 19:50:28 +03:30
masoodafar-web 04e8c49fa7 fix: IPG DiscountBalance should be 2× BasePackageAmount (112M not 56M)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m44s
Both VerifyBasePackagePayment and VerifyPackagePurchase handlers were
charging DiscountBalance with 1× amount instead of 2×.
Now consistent with DayaLoan and ManualPayment handlers.

- VerifyBasePackagePaymentCommandHandler: DiscountBalance += BasePackageAmount * 2
- VerifyPackagePurchaseCommandHandler: DiscountBalance += order.Amount * 2
- Change logs updated to reflect correct 2× discount value
2026-02-18 23:12:03 +03:30
masoodafar-web f968a6c005 docs: move README to totalDoc, replace with placeholder
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m50s
2026-02-18 21:44:46 +03:30
masoodafar-web bc710fdb7b feat: auto-create inventory records for new products + migration worker
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m52s
- CreateNewProductsCommandHandler: inject IInventoryService, auto-init
  inventory record with qty=0 when a new regular product is created
  (DiscountProduct handler already had this)
- InventoryInitializerService: one-time BackgroundService that runs on
  startup, finds existing products without InventoryItem records, and
  creates them (migration for legacy products)
- Register InventoryInitializerService in DI
2026-02-18 00:42:41 +03:30
masoodafar-web fd24dcebcd feat: Add SitePageSettings - simplified page management system
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m2s
- New entities: SitePageSettings + SitePageImage (4 fixed pages)
- EF Migration: AddSitePageSettings (SitePageSettings + SitePageImages tables)
- Proto: sitepagesettings.proto with 5 RPCs
- CQRS: GetPageSettings, GetAllPageSettings, SavePageSettings, SavePageImage, DeletePageImage
- gRPC Service: SitePageSettingsService (auto-registered)
- Proto NuGet bumped to 0.0.180
2026-02-17 22:34:29 +03:30
masoodafar-web 89e146bf32 fix: set Kestrel protocol to Http1AndHttp2 in appsettings — prevents 502
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m38s
2026-02-17 02:55:50 +03:30
masoodafar-web 27d995faf4 fix: production deployment — appsettings.Production.json + fix K8S_SERVER + set image
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m48s
- appsettings.Production.json: CmsBaseUrl=cms.kbs1.ir, FrontOfficeBaseUrl=kbs1.ir, DB=KBS
- prod-deploy.yml: K8S_SERVER 194.5.195.53 → 45.149.79.127
- prod-deploy.yml: kubectl set image instead of rollout restart
2026-02-17 01:58:07 +03:30
masoodafar-web ef1716e243 fix: proper DeliveryStatus mapping + cancel delivery on failed payment
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m22s
- Added MapDeliveryStatus() to correctly map Domain→Proto enum values
- Domain Pending(1) was incorrectly cast to Proto PROCESSING(1) instead of PROCESSING
- Set DeliveryStatus=Cancelled when payment fails (CompleteOrderPaymentCommandHandler)
- Set DeliveryStatus=Cancelled when order expires (ExpirePendingOrdersService)
2026-02-17 00:41:48 +03:30
masoodafar-web c2ccd32f39 fix: use DateTime.Now instead of UtcNow in ExpirePendingOrdersService
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m33s
Database stores Created timestamps using DateTime.Now (local time).
Background service was comparing with DateTime.UtcNow causing 3.5 hour offset (Iran timezone).
Orders would only expire after ~4 hours instead of 30 minutes.
2026-02-16 22:52:33 +03:30
masoodafar-web c435319025 feat: add PaymentStatus to discount order proto + expire pending orders background service
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m53s
- Added payment_status field to GetOrderByIdResponse and OrderSummaryDto in proto
- Proto version bumped to 0.0.179
- Added PaymentStatus mapping in DiscountOrderService gRPC responses
- Created ExpirePendingOrdersService: expires pending orders after 30 min, releases inventory
- Registered background service in ConfigureServices
2026-02-16 22:37:58 +03:30
masoodafar-web 18a65de8c7 fix: force 100% discount in PlaceOrder — ignore client DiscountBalanceToUse, always use max
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m51s
2026-02-16 21:52:02 +03:30
masoodafar-web e2e09ac4e8 fix: add CmsBaseUrl/FrontOfficeBaseUrl to appsettings.json for staging callback URLs
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m43s
- Added CmsBaseUrl=https://cms.se.kbs1.ir to appsettings.json
- Added FrontOfficeBaseUrl=https://foursat.se.kbs1.ir to appsettings.json
- Fixed PurchasePackageCommandHandler hardcoded yourdomain.com
- Development keeps localhost values via appsettings.Development.json
2026-02-16 02:31:27 +03:30
masoodafar-web 61a0d68b7c feat: add staging configuration file for environment-specific settings
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-16 02:24:22 +03:30
masoodafar-web 1c4beb5f05 chore: bump Protobuf NuGet package version to 0.0.178
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m45s
2026-02-16 01:39:49 +03:30
masoodafar-web 163a0a2f1a fix: discount store DeliveryStatus stays Pending after payment (matches regular store)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m34s
- CompleteOrderPaymentCommandHandler: DeliveryStatus.InTransit → DeliveryStatus.Pending
- PlaceOrderCommandHandler (full discount-balance path): same fix
- Now both stores behave the same: admin must manually update delivery status
- Consistent with regular store flow where order stays Pending after payment
2026-02-16 00:45:55 +03:30
masoodafar-web e77611012b fix: PaymentStatus→PaymentCompleted mapping in DiscountOrderService
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m25s
- GetOrderById and GetUserOrders were using Mapster auto-mapping which
  could not map 'PaymentStatus' (enum: Success=0) to 'payment_completed' (bool)
- Replaced with manual mapping: PaymentStatus == Success → true
- Also maps DeliveryStatus and all other fields correctly
- Fixed FrontOfficeBaseUrl and CmsBaseUrl to use HTTP for local dev
2026-02-16 00:36:29 +03:30
masoodafar-web a39a36e66d feat: add PaymentTransaction table for gateway-level tracking
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m6s
- New PaymentTransaction entity (Domain/Entities/Payment/) with all gateway fields:
  GatewayProvider, MerchantId, Authority, CardPan, CardHash, RefId, VerificationStatusCode, etc.
- New PaymentTransactionConfiguration with indexes on Authority, GatewayProvider, UserId, TransactionId, RefId
- Added DbSet<PaymentTransaction> to IApplicationDbContext and ApplicationDbContext
- Extended PaymentVerificationResult DTO with CardPan, CardHash, VerificationCode
- Updated ZarinPalPaymentService.VerifyPayment to return CardPan/CardHash/VerificationCode
- Updated all 5 payment consumers to create/update PaymentTransaction:
  * PlaceOrderCommandHandler — creates PaymentTransaction after InitiatePayment
  * PaymentCallbackController — updates PaymentTransaction after VerifyPayment
  * ChargeDiscountWalletCommandHandler — creates PaymentTransaction + fixed callback URL
  * VerifyDiscountWalletChargeCommandHandler — updates PaymentTransaction after verify
  * TransactionsService.CustomerPaymentRequest/Verification — create/update PaymentTransaction
  * PackageService.CustomerPurchasePackage/Verify — create/update PaymentTransaction
- Transaction table untouched — PaymentTransaction is a separate table
- Pattern inspired by PYMS: create row before gateway → update after verify
- EF migration: AddPaymentTransactionTable
2026-02-15 23:53:28 +03:30
masoodafar-web 207f53ef80 refactor: remove PYMS microservice, use ZarinPal directly in CMS
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m26s
- Remove PYMSPaymentService.cs and PYMS proto files
- Remove 'pyms' case from DI ConfigureServices
- Remove PYMS config from appsettings
- Switch PaymentProvider to 'zarinpal' (direct integration)
- ZarinPalPaymentService handles sandbox/production, verify, errors
- PYMS deployment/service/ingress removed from K8s
2026-02-15 23:25:20 +03:30
masoodafar-web 2502cbbda2 feat: integrate PYMS payment gateway, add blog/sitepage/image services, local file manager
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m44s
Payment Gateway:
- Add PYMSPaymentService: IPaymentGatewayService via gRPC to PYMS microservice
- Add ZarinPalPaymentService: direct ZarinPal integration (backup)
- Register 'pyms' payment provider in DI ConfigureServices
- Add PYMS proto files (pyms_transaction.proto, pyms_public_messages.proto)
- Fix VerifyDiscountWalletCharge: pass 'OK' as status instead of Authority
- Update appsettings: PaymentProvider=pyms, sandbox mode, merchant ID

Blog System:
- Add BlogCategory, BlogPost, BlogPostImage entities and CQRS
- Add proto files and gRPC services for blog management
- Add Mapster profiles for blog responses

Content Management:
- Add SitePage entity and CQRS for static pages
- Add proto and gRPC service for site pages

Image/File Management:
- Add LocalFileManager with disk storage + base64 serving + FMS fallback
- Add ImagePathResolverInterceptor for gRPC responses
- Add ImageResolverService for explicit image resolution
- Add UploadsController for public file serving with FMS fallback
- Add PaymentCallbackController for discount order payment callbacks

Database:
- Add blog and content entity migrations
- Remove ImagePath MaxLength constraints
- Remove old FileManagementService (replaced by LocalFileManager)
2026-02-15 23:01:16 +03:30
masoodafar-web 5a4e4a960d fix: Update production deployment workflow for improved Docker handling and Kubernetes integration
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m19s
2026-02-11 00:40:29 +03:30
masoodafar-web dc1e00f514 fix: Disable BuildKit for Docker image build in CI workflow
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m15s
2026-02-11 00:19:27 +03:30
masoodafar-web 9d929dc7fb fix: Add network option to Docker run and build commands in CI workflow
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m23s
2026-02-11 00:08:02 +03:30
masoodafar-web 9b7d61593b fix: Remove unnecessary security options from Docker container in CI workflow
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 23s
2026-02-10 23:41:56 +03:30
masoodafar-web e3e3e8cec4 fix: Enhance Docker container security options in CI workflow
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 13s
2026-02-10 23:13:07 +03:30
masoodafar-web 2885ff20c0 fix: Streamline Docker registry login process in CI workflow
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 15s
2026-02-10 23:08:51 +03:30
masoodafar-web f2a0b4d211 fix: Update dotnet restore command to use custom NuGet configuration
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 5s
2026-02-10 23:05:45 +03:30
masoodafar-web 6c9f3dd671 fix: Add storage driver option to Docker daemon startup in Kubernetes deployment
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 5s
2026-02-10 23:03:03 +03:30
masoodafar-web d1ca72300d refactor: Remove ICurrentUserService dependency from query handlers and update user ID handling logic
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 8s
2026-02-10 23:01:49 +03:30
249 changed files with 39076 additions and 1218 deletions
+2
View File
@@ -0,0 +1,2 @@
# Keep production config from current branch during merges — never overwrite
src/CMSMicroservice.WebApi/appsettings.Production.json merge=ours
+20 -7
View File
@@ -26,7 +26,7 @@ jobs:
} }
DAEMON DAEMON
echo "🚀 Starting Docker daemon..." echo "🚀 Starting Docker daemon..."
dockerd & dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
# Wait up to 3 minutes for Docker to be ready # Wait up to 3 minutes for Docker to be ready
for i in $(seq 1 90); do for i in $(seq 1 90); do
@@ -50,14 +50,19 @@ jobs:
run: | run: |
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/CMS.git . git clone --depth 1 --branch kub-stage 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 - name: Publish Protobuf packages
run: | run: |
echo "📦 Publishing Protobuf packages..." echo "📦 Publishing Protobuf packages..."
docker run --rm -v $(pwd):/src -w /src \ docker run --rm --network host -v $(pwd):/src -w /src \
194.5.195.53:32082/dotnet/sdk:9.0 sh -c ' 194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
for proj in $(find . -name "*Protobuf*.csproj" -type f); do for proj in $(find . -name "*Protobuf*.csproj" -type f); do
echo "📦 $proj" echo "📦 $proj"
dotnet restore "$proj" dotnet restore "$proj" --configfile src/NuGet.config
dotnet build "$proj" -c Release --no-restore dotnet build "$proj" -c Release --no-restore
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg" dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
@@ -72,18 +77,26 @@ jobs:
- name: Build Docker Image - name: Build Docker Image
run: | run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest . DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest .
- name: Push to Registry - name: Push to Registry
run: | run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Deploy to Kubernetes - name: Deploy to Kubernetes
run: | run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}" export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
# Copy K8s manifests to server
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/cms-config.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-config.yaml
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/cms-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-deployment.yaml
# Apply config (Secret) first, then deployment
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} " sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl rollout restart deployment/cms kubectl apply -f /tmp/cms-config.yaml &&
kubectl rollout status deployment/cms --timeout=180s kubectl apply -f /tmp/cms-deployment.yaml &&
kubectl rollout restart deployment/cms &&
kubectl rollout status deployment/cms --timeout=180s &&
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
" "
echo "✅ Deployed!" echo "✅ Deployed!"
+61 -47
View File
@@ -8,81 +8,95 @@ on:
env: env:
REGISTRY: 194.5.195.53:30080 REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/cms IMAGE_NAME: admin/cms
K8S_SERVER: 45.149.79.127
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker:latest image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged 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,45.149.79.127,10.0.0.0/8
steps: steps:
- name: Install dependencies - name: Start Docker daemon
run: |
apk add --no-cache git curl
# Install kubectl with fixed version
KUBECTL_VERSION="v1.31.0"
curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Start Docker daemon with insecure registry
run: | run: |
mkdir -p /etc/docker mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON' cat > /etc/docker/daemon.json << 'DAEMON'
{ {
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32082", "gitea-svc:3000"] "insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
} }
DAEMON DAEMON
mkdir -p ~/.docker echo "🚀 Starting Docker daemon..."
cat > ~/.docker/config.json << 'CONF' dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
{
"proxies": { for i in $(seq 1 90); do
"default": { if docker info >/dev/null 2>&1; then
"httpProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128", echo "✅ Docker daemon is ready (attempt $i)"
"httpsProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128", docker version
"noProxy": "localhost,127.0.0.1,gitea-svc,45.149.79.127,10.0.0.0/8" break
} else
} echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
} sleep 2
CONF fi
dockerd &
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break || sleep 2
done done
docker info
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start after 3 minutes"
exit 1
fi
- name: Checkout code - name: Checkout code
run: | run: |
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git . git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
git log -1 --format="%H %s"
- 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 - name: Build Docker Image
run: | run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \ DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \ -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod .
--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 - name: Push to Registry
run: | 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 }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
- name: Deploy to Production - name: Deploy to Production
run: | run: |
# Setup kubeconfig for PRODUCTION export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_PROD }}" | base64 -d > ~/.kube/config
# Restart deployment to pull new image # Copy K8s manifests to server
kubectl rollout restart deployment/cms || echo "Deployment doesn't exist yet" sshpass -e scp -o StrictHostKeyChecking=no k8s/production/cms-config.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-config.yaml
sshpass -e scp -o StrictHostKeyChecking=no k8s/production/cms-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-deployment.yaml
# Wait for rollout to complete # Apply config (Secret) first, then deployment and update image
kubectl rollout status deployment/cms --timeout=5m || echo "Deployment rollout pending" sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl apply -f /tmp/cms-config.yaml &&
kubectl apply -f /tmp/cms-deployment.yaml &&
kubectl set image deployment/cms cms=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} &&
kubectl rollout status deployment/cms --timeout=300s &&
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
"
echo "✅ Deployed to Production!"
+2 -444
View File
@@ -1,445 +1,3 @@
# CMS Microservice - Network & Club Commission + Inventory Management System # CMS Microservice
[![Status](https://img.shields.io/badge/Status-Active%20Development-success)]() Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md).
[![Progress](https://img.shields.io/badge/Inventory%20System-Phase%202%20Complete-blue)]()
[![Phase](https://img.shields.io/badge/Next-Business%20Services-orange)]()
## 📊 Project Status (January 2026)
### 🏪 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 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
- Phase 10: Withdrawal & Settlement (40%)
- ✅ Commands & Database
- ❌ Payment Gateway Integration
### ❌ Not Started
- Phase 9: Club Shop & Product Integration (0%)
---
## 🚀 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)
-**Kavenegar 1.2.5** for SMS (Iranian SMS gateway)
- ✅ User.Email field added with migration
- ✅ 3 notification types: Commission, Club activation, Errors
- ✅ Persian RTL templates with rich formatting
- ✅ Production configuration guide created
### Hangfire Job Scheduling - COMPLETED ✅
- ✅ Dashboard UI at `/hangfire`
- ✅ Cron schedule: Sunday 00:05 UTC
- ✅ SQL Server persistence
- ✅ Manual trigger API endpoints
- ✅ Distributed execution support
### Infrastructure Enhancements - COMPLETED ✅
- ✅ Health Check endpoints (`/health`, `/health/ready`, `/health/live`)
- ✅ AlertService (structured logging for Sentry/Slack)
- ✅ Retry logic (Polly 8.5.0 with exponential backoff)
- ✅ WorkerExecutionLog (database audit trail)
- ✅ CurrentUserService (JWT authentication context)
---
## 🏗️ Architecture
**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
```
**Technology Stack**:
- .NET 9.0
- Entity Framework Core 9.0.11
- gRPC + JSON Transcoding
- Hangfire 1.8.22 (Job Scheduling)
- MediatR 13.0.0 (CQRS)
- Polly 8.5.0 (Resilience)
- MailKit 4.14.1 (Email)
- Kavenegar 1.2.5 (SMS)
- SQL Server
---
## 📖 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
- **[Binary Tree Registration](docs/binary-tree-registration-guide.md)** - Network tree guide
- **[Network Club Commission System](docs/network-club-commission-system-v1.1.md)** - Full system specification
---
## 🏪 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
- .NET 9.0 SDK
- SQL Server (local or remote)
- (Optional) Gmail account for Email
- (Optional) Kavenegar account for SMS
### 1. Clone & Build
```bash
cd /home/masoud/Apps/project/FourSat/CMS/src
dotnet build
```
### 2. Configure Database
Update `appsettings.json` with your SQL Server connection:
```json
"ConnectionStrings": {
"DefaultConnection": "Server=YOUR_SERVER;Database=Foursat_CMS;..."
}
```
### 3. Apply Migrations
```bash
cd CMSMicroservice.WebApi
dotnet ef database update
```
### 4. Configure Notifications (Optional)
See [Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)
### 5. Run
```bash
dotnet run --urls="http://localhost:5133"
```
### 6. Access Endpoints
- **Health**: http://localhost:5133/health
- **Hangfire Dashboard**: http://localhost:5133/hangfire
- **gRPC**: localhost:5133 (HTTP/2)
---
## 🔧 Configuration
### Email (SMTP)
```json
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUsername": "your-email@gmail.com",
"SmtpPassword": "your-gmail-app-password",
"FromEmail": "noreply@foursat.com",
"FromName": "FourSat CMS",
"EnableSsl": true
}
```
### SMS (Kavenegar)
```json
"Sms": {
"Enabled": true,
"Provider": "Kavenegar",
"KavenegarApiKey": "YOUR_API_KEY",
"Sender": "10008663"
}
```
### Background Worker
```csharp
// Cron: "5 0 * * 0" = Every Sunday at 00:05 UTC
RecurringJob.AddOrUpdate<WeeklyCommissionJob>(
"weekly-commission-calculation",
job => job.ExecuteAsync(CancellationToken.None),
"5 0 * * 0");
```
---
## 🧪 Testing
### Manual Trigger (via API)
```bash
# Trigger weekly calculation immediately
curl -X POST http://localhost:5133/api/admin/trigger-weekly-calculation
# Trigger recurring job now
curl -X POST http://localhost:5133/api/admin/trigger-recurring-job-now
# Get recurring jobs status
curl http://localhost:5133/api/admin/recurring-jobs-status
```
### Health Checks
```bash
curl http://localhost:5133/health # Overall health
curl http://localhost:5133/health/ready # Readiness probe (K8s)
curl http://localhost:5133/health/live # Liveness probe (K8s)
```
---
## 📊 What's Remaining?
### 🏪 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
- Admin approval UI in BackOffice
2. **Production Configuration** (30 minutes)
- Gmail App Password setup
- Kavenegar API key registration
- Update `appsettings.Production.json`
### Medium Priority
3. **Club Shop Integration** (Phase 9 - 2 weeks)
- Product catalog for club memberships
- Shopping cart integration
- Auto-activation on purchase
### Low Priority
4. **Testing** (Phase 7 - Postponed)
- Unit tests for business logic
- Integration tests for API
- Load testing for background worker
### Optional Enhancements
- Redis distributed locks (multi-server deployment)
- Sentry error tracking (API key needed)
- Slack notifications (webhook needed)
- FCM push notifications
---
## 🎯 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)
✅ Background worker with Hangfire (cron scheduling)
✅ Balance carryover logic (rollover unused volumes)
✅ MaxWeeklyBalances cap enforcement
✅ Health check endpoints (Kubernetes-ready)
✅ Manual trigger API (admin control)
✅ Email + SMS notifications (MailKit + Kavenegar)
✅ Retry logic with exponential backoff (Polly)
✅ Audit trail (WorkerExecutionLog, History tables)
✅ 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**: January 2026
---
## 📝 License
Proprietary - FourSat Company
# Multi-remote push enabled
+54
View File
@@ -0,0 +1,54 @@
---
# CMS Application Configuration Secret (Production)
# This Secret stores appsettings.Production.json externally in K8s
# so configuration persists independently of Docker image rebuilds.
#
# To edit config without redeploying:
# kubectl edit secret cms-appsettings
# kubectl rollout restart deployment/cms
#
apiVersion: v1
kind: Secret
metadata:
name: cms-appsettings
namespace: default
labels:
app: cms
environment: production
type: Opaque
stringData:
appsettings.Production.json: |
{
"ZarinPal": {
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": false
},
"CmsBaseUrl": "https://cms.kbs1.ir",
"FrontOfficeBaseUrl": "https://kbs1.ir",
"ConnectionStrings": {
"DefaultConnection": "Server=mssql-svc;Database=KBS;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;"
},
"SeedWorkers": {
"MagicWalletCycleSeed": {
"Enabled": true
}
},
"FileStorage": {
"UploadPath": "/app/Uploads"
},
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
},
"Seq": {
"ServerUrl": "http://seq-svc:5341",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
}
}
+139
View File
@@ -0,0 +1,139 @@
---
# CMS Uploads PersistentVolumeClaim (Production)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cms-uploads-pvc
namespace: default
labels:
app: cms
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
# CMS Microservice Deployment (Production)
apiVersion: apps/v1
kind: Deployment
metadata:
name: cms
namespace: default
labels:
app: cms
spec:
replicas: 1
selector:
matchLabels:
app: cms
template:
metadata:
labels:
app: cms
spec:
containers:
- name: cms
image: 194.5.195.53:30080/admin/cms:prod
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: ASPNETCORE_URLS
value: "http://+:8080"
volumeMounts:
- name: cms-uploads
mountPath: /app/Uploads
- name: cms-config
mountPath: /app/appsettings.Production.json
subPath: appsettings.Production.json
readOnly: true
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: cms-uploads
persistentVolumeClaim:
claimName: cms-uploads-pvc
- name: cms-config
secret:
secretName: cms-appsettings
---
# CMS Service (Production)
apiVersion: v1
kind: Service
metadata:
name: cms-svc
namespace: default
spec:
selector:
app: cms
ports:
- name: http
port: 8080
targetPort: 8080
type: ClusterIP
---
# CMS Ingress (Production)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cms-ingress
namespace: default
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- cms.kbs1.ir
- cms.kbs2.ir
secretName: cms-tls
rules:
- host: cms.kbs2.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cms-svc
port:
number: 8080
- host: cms.kbs1.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cms-svc
port:
number: 8080
+111
View File
@@ -0,0 +1,111 @@
---
# CMS Application Configuration Secret (Staging)
# This Secret stores appsettings.Staging.json externally in K8s
# so configuration persists independently of Docker image rebuilds.
#
# To edit config without redeploying:
# kubectl edit secret cms-appsettings
# kubectl rollout restart deployment/cms
#
apiVersion: v1
kind: Secret
metadata:
name: cms-appsettings
namespace: default
labels:
app: cms
environment: staging
type: Opaque
stringData:
appsettings.Staging.json: |
{
"PaymentProvider": "zarinpal",
"ZarinPal": {
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": true
},
"FMS": {
"Address": "https://dl.afrino.co"
},
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"DefaultConnection": "Data Source=194.5.195.53,31433; Initial Catalog=Foursat;User ID=sa;Password=87zH26nbqT;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"providerName": "System.Data.SqlClient"
},
"Otp": {
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
},
"Monitoring": {
"SentryEnabled": false,
"SentryDsn": "",
"SlackEnabled": false,
"SlackWebhookUrl": "",
"EmailAlertsEnabled": false,
"AdminEmails": ["admin@example.com"],
"SmsNotificationsEnabled": false,
"SmsApiKey": "",
"SmsGatewayUrl": ""
},
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUsername": "your-email@gmail.com",
"SmtpPassword": "your-app-password",
"FromEmail": "noreply@foursat.com",
"FromName": "FourSat CMS",
"EnableSsl": true
},
"Sms": {
"Enabled": true,
"Provider": "Kavenegar",
"KavenegarApiKey": "497263626F32626A48685A6137524C4F78575A766E4C74694A556B79317648424964655030682B554545413D",
"Sender": "1000001110100"
},
"DayaPayment": {
"BaseUrl": "https://api.daya.ir",
"ApiKey": "YOUR_DAYA_API_KEY"
},
"DayaApi": {
"UseMock": false,
"BaseAddress": "https://Dayadiamond.ir",
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
"CacheDurationMinutes": 20
},
"Chatika": {
"Enabled": true,
"BaseUrl": "https://api.chatika.ir",
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
},
"BackgroundJobs": {
"WeeklyCommissionCalculation": {
"Enabled": true,
"CronExpression": "5 0 * * 0"
}
},
"SeedWorkers": {
"MagicWalletCycleSeed": {
"Enabled": true
}
},
"FileStorage": {
"UploadPath": "/app/Uploads"
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http1AndHttp2"
}
},
"Authentication": {
"Authority": "https://ids.domain.com/",
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "https://seq.afrino.co",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
}
}
+128
View File
@@ -0,0 +1,128 @@
---
# CMS Uploads PersistentVolumeClaim
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cms-uploads-pvc
namespace: default
labels:
app: cms
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
# CMS Microservice Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: cms
namespace: default
labels:
app: cms
spec:
replicas: 1
selector:
matchLabels:
app: cms
template:
metadata:
labels:
app: cms
spec:
containers:
- name: cms
image: 194.5.195.53:30080/admin/cms:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Staging"
- name: ASPNETCORE_URLS
value: "http://+:8080"
volumeMounts:
- name: cms-uploads
mountPath: /app/Uploads
- name: cms-config
mountPath: /app/appsettings.Staging.json
subPath: appsettings.Staging.json
readOnly: true
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: cms-uploads
persistentVolumeClaim:
claimName: cms-uploads-pvc
- name: cms-config
secret:
secretName: cms-appsettings
---
# CMS Service
apiVersion: v1
kind: Service
metadata:
name: cms-svc
namespace: default
spec:
selector:
app: cms
ports:
- name: http
port: 8080
targetPort: 8080
type: ClusterIP
---
# CMS Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cms-ingress
namespace: default
annotations:
kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- cms.se.kbs1.ir
secretName: cms-tls
rules:
- host: cms.se.kbs1.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cms-svc
port:
number: 8080
@@ -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;
}
@@ -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;
}
}
@@ -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("نام آیکون حداکثر ۱۰۰ کاراکتر");
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.DeleteBlogCategory;
public class DeleteBlogCategoryCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -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;
}
}
@@ -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; }
}
@@ -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;
}
}
@@ -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("نام آیکون حداکثر ۱۰۰ کاراکتر");
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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 };
}
}
@@ -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();
}
@@ -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; }
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
public class GetBlogCategoryQuery : IRequest<BlogCategoryDto>
{
public long Id { get; set; }
}
@@ -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
};
}
}
@@ -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;
}
@@ -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 = "مقاله آرشیو شد" };
}
}
@@ -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; }
}
@@ -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;
}
}
@@ -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("محتوای مقاله الزامی است");
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.DeleteBlogPost;
public class DeleteBlogPostCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -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;
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.IncrementViewCount;
public class IncrementViewCountCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -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;
}
}
@@ -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; }
}
@@ -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
};
}
}
@@ -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; }
}
@@ -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;
}
}
@@ -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("محتوای مقاله الزامی است");
}
}
@@ -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; }
}
@@ -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 };
}
}
@@ -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; }
}
@@ -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 => "آرشیو",
_ => "نامشخص"
};
}
@@ -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;
}
@@ -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()
};
}
}
@@ -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;
}
}
@@ -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 };
}
}
@@ -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; }
}
@@ -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;
}
}
@@ -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("عنوان تصویر حداکثر ۱۰۰۰ کاراکتر");
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.DeleteBlogPostImage;
public class DeleteBlogPostImageCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -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;
}
}
@@ -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; }
}
@@ -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;
}
}
@@ -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; }
}
@@ -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;
}
}
@@ -31,7 +31,18 @@ public class GetAllCategoryByFilterQueryHandler : IRequestHandler<GetAllCategory
{ {
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken), MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query.PaginatedListAsync(paginationState: request.PaginationState) 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)
}; };
} }
} }
@@ -24,4 +24,6 @@ public class GetAllCategoryByFilterResponseDto
public bool IsActive { get; set; } public bool IsActive { get; set; }
//ترتیب نمایش //ترتیب نمایش
public int SortOrder { get; set; } public int SortOrder { get; set; }
//تعداد محصولات
public int ProductCount { get; set; }
} }
@@ -53,20 +53,28 @@ public class AcceptClubMembershipContractCommandHandler
AcceptClubMembershipContractCommand request, AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// خواندن UserId از JWT (امن‌تر از دریافت از کلاینت)
if (!long.TryParse(_currentUser.UserId, out var userId))
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "کاربر احراز هویت نشده است"
};
_logger.LogInformation( _logger.LogInformation(
"Processing club membership contract for UserId: {UserId}", "Processing club membership contract for UserId: {UserId}",
request.UserId userId
); );
// 1. دریافت کاربر // 1. دریافت کاربر
var user = await _context.Users var user = await _context.Users
.Include(u => u.ClubMembership) .Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) if (user == null)
{ {
_logger.LogWarning("User not found: {UserId}", request.UserId); _logger.LogWarning("User not found: {UserId}", userId);
throw new NotFoundException(nameof(User), request.UserId); throw new NotFoundException(nameof(User), userId);
} }
// 2. بررسی خرید پکیج // 2. بررسی خرید پکیج
@@ -4,10 +4,6 @@ public class AcceptClubMembershipContractCommandValidator : AbstractValidator<Ac
{ {
public AcceptClubMembershipContractCommandValidator() public AcceptClubMembershipContractCommandValidator()
{ {
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر الزامی است");
RuleFor(x => x.OtpCode) RuleFor(x => x.OtpCode)
.NotEmpty() .NotEmpty()
.WithMessage("کد تایید الزامی است") .WithMessage("کد تایید الزامی است")
@@ -12,4 +12,9 @@ public record ActivateClubMembershipCommand : IRequest<bool>
/// شناسه کاربر /// شناسه کاربر
/// </summary> /// </summary>
public long UserId { get; init; } public long UserId { get; init; }
/// <summary>
/// فعال‌سازی اجباری توسط ادمین — بدون بررسی سفارش و کیف‌پول
/// </summary>
public bool ForceActivation { get; init; }
} }
@@ -54,82 +54,113 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
throw new NotFoundException(nameof(User), request.UserId); throw new NotFoundException(nameof(User), request.UserId);
} }
// 2. بررسی اینکه پکیج خریده باشد // 2-5: بررسی‌های مالی — در حالت ForceActivation (ادمین) رد می‌شود
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None) if (!request.ForceActivation)
{ {
_logger.LogWarning( // 2. بررسی اینکه پکیج خریده باشد
"User {UserId} has not purchased golden package yet", if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
_logger.LogWarning(
"User {UserId} has not purchased golden package yet",
request.UserId
);
throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
);
}
// 3. بررسی موجودی کیف پول
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
if (wallet == null)
{
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
if (wallet.Balance < SystemConstants.BasePackageAmount)
{
_logger.LogWarning(
"User {UserId} has insufficient balance: {Balance}",
request.UserId,
wallet.Balance
);
throw new BadRequestException(
$"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
);
}
// 3.5. بررسی وضعیت کیف‌پول جادویی — اگر در حالت Magic است، فعالسازی مجاز نیست
if (wallet.WalletMode == WalletMode.Magic)
{
_logger.LogWarning(
"User {UserId} cannot activate club while in Magic wallet mode",
request.UserId
);
throw new BadRequestException(
"ابتدا باید چرخه کیف‌پول جادویی تکمیل شود"
);
}
// 4. پیدا کردن UserOrder با PackageId
var packageOrder = await _context.UserOrders
.Include(o => o.Transaction)
.Where(o =>
o.UserId == user.Id &&
o.PackageId != null &&
o.PaymentStatus == PaymentStatus.Success)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
if (packageOrder == null)
{
_logger.LogWarning(
"No successful package order found for UserId: {UserId}",
request.UserId
);
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
}
// 5. بررسی Transaction
if (packageOrder.Transaction == null)
{
_logger.LogError(
"Transaction not found for OrderId: {OrderId}",
packageOrder.Id
);
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
}
var transaction = packageOrder.Transaction;
if (transaction.Type != TransactionType.DepositIpg &&
transaction.Type != TransactionType.DepositExternal1)
{
_logger.LogWarning(
"Invalid transaction type for OrderId {OrderId}: {Type}",
packageOrder.Id,
transaction.Type
);
throw new BadRequestException(
"تراکنش معتبر برای فعالسازی باشگاه یافت نشد"
);
}
}
else
{
_logger.LogInformation(
"Force activation by admin for UserId: {UserId} — skipping order/wallet validation",
request.UserId request.UserId
); );
throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
);
}
// 3. بررسی موجودی کیف پول // در فعال‌سازی اجباری، اگر PackagePurchaseMethod تنظیم نشده، مقدار DirectPurchase بگذار
var wallet = await _context.UserWallets if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken); {
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
if (wallet == null) _context.Users.Update(user);
{ await _context.SaveChangesAsync(cancellationToken);
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId); }
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
if (wallet.Balance < SystemConstants.BasePackageAmount)
{
_logger.LogWarning(
"User {UserId} has insufficient balance: {Balance}",
request.UserId,
wallet.Balance
);
throw new BadRequestException(
$"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
);
}
// 4. پیدا کردن UserOrder با PackageId
var packageOrder = await _context.UserOrders
.Include(o => o.Transaction)
.Where(o =>
o.UserId == user.Id &&
o.PackageId != null &&
o.PaymentStatus == PaymentStatus.Success)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
if (packageOrder == null)
{
_logger.LogWarning(
"No successful package order found for UserId: {UserId}",
request.UserId
);
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
}
// 5. بررسی Transaction
if (packageOrder.Transaction == null)
{
_logger.LogError(
"Transaction not found for OrderId: {OrderId}",
packageOrder.Id
);
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
}
var transaction = packageOrder.Transaction;
if (transaction.Type != TransactionType.DepositIpg &&
transaction.Type != TransactionType.DepositExternal1)
{
_logger.LogWarning(
"Invalid transaction type for OrderId {OrderId}: {Type}",
packageOrder.Id,
transaction.Type
);
throw new BadRequestException(
"تراکنش معتبر برای فعالسازی باشگاه یافت نشد"
);
} }
// 6. بررسی عضویت فعلی // 6. بررسی عضویت فعلی
@@ -183,10 +214,9 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
return true; return true;
} }
// فعال‌سازی مجدد // فعال‌سازی مجدد — ActivatedAt حفظ میشه (overwrite نمیشه)
entity = existingMembership; entity = existingMembership;
entity.IsActive = true; entity.IsActive = true;
entity.ActivatedAt = activationDate;
entity.PurchaseMethod = user.PackagePurchaseMethod; entity.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(entity); _context.ClubMemberships.Update(entity);
@@ -199,6 +229,41 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// 6.5. ایجاد ClubMembershipCycle — هر خرید پکیج یک دور جدید
var previousCycles = await _context.ClubMembershipCycles
.Where(c => c.UserId == user.Id && c.IsCurrentCycle)
.ToListAsync(cancellationToken);
foreach (var prevCycle in previousCycles)
{
prevCycle.IsCurrentCycle = false;
}
var maxCycleNumber = await _context.ClubMembershipCycles
.Where(c => c.ClubMembershipId == entity.Id)
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
var newCycle = new ClubMembershipCycle
{
UserId = user.Id,
ClubMembershipId = entity.Id,
CycleNumber = maxCycleNumber + 1,
PackagePurchasedAt = activationDate,
PurchaseMethod = user.PackagePurchaseMethod,
PackageAmount = SystemConstants.BasePackageAmount,
IsCurrentCycle = true
};
_context.ClubMembershipCycles.Add(newCycle);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Created ClubMembershipCycle #{CycleNumber} for UserId {UserId}, MembershipId {MembershipId}",
newCycle.CycleNumber,
user.Id,
entity.Id
);
// 7. ثبت تاریخچه // 7. ثبت تاریخچه
var history = new ClubMembershipHistory var history = new ClubMembershipHistory
{ {
@@ -43,8 +43,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
// ⭐ دریافت همه کاربرانی که عضو فعال باشگاه هستند // ⭐ دریافت همه کاربرانی که عضو فعال باشگاه هستند
// بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند // بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند
// ⚠️ Magic Wallet: کاربرهایی که در حالت جادویی هستند از کمیسیون خارج میشن
var magicModeUserIds = await _context.UserWallets
.Where(w => w.WalletMode == WalletMode.Magic)
.Select(w => w.UserId)
.ToHashSetAsync(cancellationToken);
var activeClubMemberUserIds = await _context.ClubMemberships var activeClubMemberUserIds = await _context.ClubMemberships
.Where(c => c.IsActive) .Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
.Select(c => c.UserId) .Select(c => c.UserId)
.ToHashSetAsync(cancellationToken); .ToHashSetAsync(cancellationToken);
@@ -256,11 +262,12 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var count = 0; var count = 0;
// اگر فرزند در این هفته فعال شده، 1 امتیاز // اگر فرزند در این هفته پکیج خریده (Cycle جدید ساخته شده)، 1 امتیاز
var membership = await _context.ClubMemberships // ⚠️ از ClubMembershipCycle.PackagePurchasedAt استفاده میکنیم (نه ActivatedAt که overwrite میشد)
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken); var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsCurrentCycle, cancellationToken);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate) if (currentCycle?.PackagePurchasedAt >= startDate && currentCycle?.PackagePurchasedAt <= endDate)
{ {
count = 1; count = 1;
} }
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto> public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
{ {
/// <summary> /// <summary>
/// از هفته (فیلتر اختیاری) /// از هفته — WeekDefinitionId (فیلتر اختیاری)
/// </summary> /// </summary>
public int? FromWeekOrder { get; init; } public long? FromWeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// تا هفته (فیلتر اختیاری) /// تا هفته — WeekDefinitionId (فیلتر اختیاری)
/// </summary> /// </summary>
public int? ToWeekOrder { get; init; } public long? ToWeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// فقط Pool های محاسبه شده /// فقط Pool های محاسبه شده
@@ -20,14 +20,14 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
.AsNoTracking(); .AsNoTracking();
// Apply filters // 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) if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
@@ -4,16 +4,13 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository; private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ICurrentUserService _currentUser;
public GetUserCommissionPayoutsQueryHandler( public GetUserCommissionPayoutsQueryHandler(
IApplicationDbContext context, IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository, IWeekDefinitionRepository weekDefinitionRepository)
ICurrentUserService currentUser)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository; _weekDefinitionRepository = weekDefinitionRepository;
_currentUser = currentUser;
} }
public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken) public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken)
@@ -24,17 +21,11 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
// اگر UserId داده نشده، از CurrentUser بگیر (برای Customer API) // 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; long? userId = request.UserId;
if (!userId.HasValue || userId.Value == 0)
{
if (long.TryParse(_currentUser.UserId, out var currentUserId))
{
userId = currentUserId;
}
}
// فیلترها
if (userId.HasValue && userId.Value > 0) if (userId.HasValue && userId.Value > 0)
{ {
query = query.Where(x => x.UserId == userId.Value); query = query.Where(x => x.UserId == userId.Value);
@@ -4,16 +4,13 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository; private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ICurrentUserService _currentUser;
public GetUserWeeklyBalancesQueryHandler( public GetUserWeeklyBalancesQueryHandler(
IApplicationDbContext context, IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository, IWeekDefinitionRepository weekDefinitionRepository)
ICurrentUserService currentUser)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository; _weekDefinitionRepository = weekDefinitionRepository;
_currentUser = currentUser;
} }
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken) public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
@@ -24,17 +21,11 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
// اگر UserId داده نشده، از CurrentUser بگیر (برای Customer API) // 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; long? userId = request.UserId;
if (!userId.HasValue || userId.Value == 0)
{
if (long.TryParse(_currentUser.UserId, out var currentUserId))
{
userId = currentUserId;
}
}
// فیلترها
if (userId.HasValue && userId.Value > 0) if (userId.HasValue && userId.Value > 0)
{ {
query = query.Where(x => x.UserId == userId.Value); query = query.Where(x => x.UserId == userId.Value);
@@ -18,7 +18,10 @@ public class LoggingBehaviour<TRequest> : IRequestPreProcessor<TRequest> where T
{ {
var requestName = typeof(TRequest).Name; var requestName = typeof(TRequest).Name;
var userId = _currentUserService.UserId ?? string.Empty; var userId = _currentUserService.UserId ?? string.Empty;
_logger.LogInformation("Request: {Name} {@UserId} {@Request}", var safeLog = request?.ToString() ?? "";
requestName, userId, request); 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.Diagnostics;
using System.Text.RegularExpressions;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.Common.Behaviours; 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> where TRequest : IRequest<TResponse>
{ {
private readonly Stopwatch _timer; private readonly Stopwatch _timer;
private readonly ILogger<TRequest> _logger; private readonly ILogger<TRequest> _logger;
private readonly ICurrentUserService _currentUserService; 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) public PerformanceBehaviour(ILogger<TRequest> logger, ICurrentUserService currentUserService)
{ {
_timer = new Stopwatch(); _timer = new Stopwatch();
@@ -33,11 +37,20 @@ public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequ
{ {
var requestName = typeof(TRequest).Name; var requestName = typeof(TRequest).Name;
var userId = _currentUserService.UserId ?? string.Empty; var userId = _currentUserService.UserId ?? string.Empty;
var safeLog = SanitizeForLog(request);
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@Request}", _logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {UserId} {Request}",
requestName, elapsedMilliseconds, userId, request); requestName, elapsedMilliseconds, userId, safeLog);
} }
return response; 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) catch (Exception ex)
{ {
var requestName = typeof(TRequest).Name; 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; 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,3 +1,5 @@
using CMSMicroservice.Domain.Entities.Blog;
using CMSMicroservice.Domain.Entities.Content;
using CMSMicroservice.Domain.Entities.Payment; using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order; using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop; using CMSMicroservice.Domain.Entities.DiscountShop;
@@ -32,9 +34,11 @@ public interface IApplicationDbContext
DbSet<UserWallet> UserWallets { get; } DbSet<UserWallet> UserWallets { get; }
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; } DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
DbSet<ManualPayment> ManualPayments { get; } DbSet<ManualPayment> ManualPayments { get; }
DbSet<PaymentTransaction> PaymentTransactions { get; }
DbSet<PublicMessage> PublicMessages { get; } DbSet<PublicMessage> PublicMessages { get; }
DbSet<ClubMembership> ClubMemberships { get; } DbSet<ClubMembership> ClubMemberships { get; }
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; } DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
DbSet<ClubMembershipCycle> ClubMembershipCycles { get; }
DbSet<ClubFeature> ClubFeatures { get; } DbSet<ClubFeature> ClubFeatures { get; }
DbSet<UserClubFeature> UserClubFeatures { get; } DbSet<UserClubFeature> UserClubFeatures { get; }
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; } DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
@@ -66,6 +70,21 @@ public interface IApplicationDbContext
DbSet<InventoryItem> InventoryItems { get; } DbSet<InventoryItem> InventoryItems { get; }
DbSet<StockMovement> StockMovements { 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; }
// ============= Site Page Settings (Simplified) =============
DbSet<SitePageSettings> SitePageSettingsEntities { get; }
DbSet<SitePageImage> SitePageImages { get; }
/// <summary> /// <summary>
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures /// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
/// </summary> /// </summary>
@@ -1,37 +0,0 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Service for uploading files to FMS (File Management Service)
/// </summary>
public interface IFileManagementService
{
/// <summary>
/// Uploads a file to FMS and returns the stored file path
/// </summary>
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
/// <param name="fileBytes">Raw file bytes</param>
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
/// <param name="fileName">Original file name</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The stored file path returned by FMS, or null if upload failed</returns>
Task<string?> UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName, CancellationToken cancellationToken = default);
/// <summary>
/// Uploads an image to FMS with optimization (resize + compress)
/// Returns both main image path and thumbnail path
/// </summary>
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
/// <param name="fileBytes">Raw image bytes</param>
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
/// <param name="fileName">Original file name</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Tuple of (mainImagePath, thumbnailPath), either can be null if upload failed</returns>
Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync(
string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a file from FMS by its ID
/// </summary>
Task<bool> DeleteFileAsync(long fileId, CancellationToken cancellationToken = default);
}
@@ -27,6 +27,24 @@ public interface IPaymentGatewayService
string verificationToken, string verificationToken,
CancellationToken cancellationToken = default); 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>
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول) /// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
/// </summary> /// </summary>
@@ -124,6 +142,21 @@ public class PaymentVerificationResult
/// پیام /// پیام
/// </summary> /// </summary>
public string? Message { get; set; } 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> /// <summary>
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.Common.Services;
public static class VatCalculator public static class VatCalculator
{ {
/// <summary> /// <summary>
/// نرخ VAT ایران - 9 درصد /// نرخ VAT ایران - 10 درصد
/// </summary> /// </summary>
public const decimal VAT_RATE = 0.09m; public const decimal VAT_RATE = 0.10m;
/// <summary> /// <summary>
/// نرخ VAT به صورت درصد (9) /// نرخ VAT به صورت درصد (10)
/// </summary> /// </summary>
public const int VAT_PERCENT = 9; public const int VAT_PERCENT = 10;
/// <summary> /// <summary>
/// محاسبه VAT از مبلغ خالص /// محاسبه VAT از مبلغ خالص
@@ -72,6 +72,17 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
result.UserId = user.Id; result.UserId = user.Id;
// 2.5. بررسی محدودیت دایا — پس از اولین دور، فقط IPG مجاز است
var hasPreviousCycle = await _context.ClubMembershipCycles
.AnyAsync(c => c.UserId == user.Id, cancellationToken);
if (hasPreviousCycle)
{
result.Message = "کاربرانی که دور اول را تکمیل کرده‌اند فقط از طریق درگاه بانکی می‌توانند پکیج خریداری کنند";
results.Add(result);
continue;
}
// 3. ذخیره/به‌روزرسانی DayaLoanContract // 3. ذخیره/به‌روزرسانی DayaLoanContract
var contract = await _context.DayaLoanContracts var contract = await _context.DayaLoanContracts
.FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken); .FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken);
@@ -9,4 +9,9 @@ public class AddDiscountProductImageCommand : IRequest<long>
public string ThumbnailPath { get; set; } = string.Empty; public string ThumbnailPath { get; set; } = string.Empty;
public string? Title { get; set; } public string? Title { get; set; }
public string? AltText { get; set; } public string? AltText { get; set; }
// Image file upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
} }
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.DiscountShop; using CMSMicroservice.Domain.Entities.DiscountShop;
using MediatR; using MediatR;
@@ -8,10 +9,12 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.AddDiscountProduct
public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscountProductImageCommand, long> public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscountProductImageCommand, long>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public AddDiscountProductImageCommandHandler(IApplicationDbContext context) public AddDiscountProductImageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{ {
_context = context; _context = context;
_fileManager = fileManager;
} }
public async Task<long> Handle(AddDiscountProductImageCommand request, CancellationToken cancellationToken) public async Task<long> Handle(AddDiscountProductImageCommand request, CancellationToken cancellationToken)
@@ -23,6 +26,23 @@ public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscount
if (!productExists) if (!productExists)
throw new InvalidOperationException($"DiscountProduct with Id {request.DiscountProductId} not found."); throw new InvalidOperationException($"DiscountProduct with Id {request.DiscountProductId} not found.");
var imagePath = request.ImagePath;
var thumbnailPath = request.ThumbnailPath;
// آپلود تصویر (اگر فایل ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/DiscountProducts/Gallery",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
imagePath = result.Main.Path;
thumbnailPath = result.Thumbnail.Path;
}
// Get the max sort order for this product // Get the max sort order for this product
var maxSortOrder = await _context.DiscountProductImages var maxSortOrder = await _context.DiscountProductImages
.Where(i => i.DiscountProductId == request.DiscountProductId) .Where(i => i.DiscountProductId == request.DiscountProductId)
@@ -31,8 +51,8 @@ public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscount
var image = new DiscountProductImage var image = new DiscountProductImage
{ {
DiscountProductId = request.DiscountProductId, DiscountProductId = request.DiscountProductId,
ImagePath = request.ImagePath, ImagePath = imagePath,
ThumbnailPath = request.ThumbnailPath, ThumbnailPath = thumbnailPath,
Title = request.Title, Title = request.Title,
AltText = request.AltText, AltText = request.AltText,
SortOrder = maxSortOrder + 1, SortOrder = maxSortOrder + 1,
@@ -56,7 +56,7 @@ public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderP
// Update order // Update order
order.PaymentStatus = PaymentStatus.Success; order.PaymentStatus = PaymentStatus.Success;
order.PaymentDate = DateTime.Now; order.PaymentDate = DateTime.Now;
order.DeliveryStatus = DeliveryStatus.InTransit; order.DeliveryStatus = DeliveryStatus.Pending;
// Deduct discount balance from user wallet // Deduct discount balance from user wallet
var userWallet = await _context.UserWallets var userWallet = await _context.UserWallets
@@ -105,6 +105,7 @@ public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderP
transaction.PaymentStatus = PaymentStatus.Reject; transaction.PaymentStatus = PaymentStatus.Reject;
order.PaymentStatus = PaymentStatus.Reject; order.PaymentStatus = PaymentStatus.Reject;
order.DeliveryStatus = DeliveryStatus.Cancelled;
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
@@ -11,5 +11,15 @@ public class CreateDiscountProductCommand : IRequest<long>
public int MaxDiscountPercent { get; set; } public int MaxDiscountPercent { get; set; }
public string ImagePath { get; set; } public string ImagePath { get; set; }
public string ThumbnailPath { get; set; } public string ThumbnailPath { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public List<long> CategoryIds { get; set; } = new(); public List<long> CategoryIds { get; set; } = new();
// Image file upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
public byte[]? ThumbnailFileBytes { get; set; }
public string? ThumbnailFileMime { get; set; }
public string? ThumbnailFileName { get; set; }
} }
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.DiscountShop; using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
@@ -10,13 +11,16 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService; private readonly IInventoryService _inventoryService;
private readonly IFileManager _fileManager;
public CreateDiscountProductCommandHandler( public CreateDiscountProductCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IInventoryService inventoryService) IInventoryService inventoryService,
IFileManager fileManager)
{ {
_context = context; _context = context;
_inventoryService = inventoryService; _inventoryService = inventoryService;
_fileManager = fileManager;
} }
public async Task<long> Handle(CreateDiscountProductCommand request, CancellationToken cancellationToken) public async Task<long> Handle(CreateDiscountProductCommand request, CancellationToken cancellationToken)
@@ -28,15 +32,42 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
FullInformation = request.FullInformation, FullInformation = request.FullInformation,
Price = request.Price, Price = request.Price,
MaxDiscountPercent = request.MaxDiscountPercent, MaxDiscountPercent = request.MaxDiscountPercent,
ImagePath = request.ImagePath, ImagePath = request.ImagePath ?? string.Empty,
ThumbnailPath = request.ThumbnailPath, ThumbnailPath = request.ThumbnailPath ?? string.Empty,
RemainingCount = 0, // موجودی اولیه صفر - باید از طریق Inventory اضافه شود RemainingCount = 0,
Rate = 0, Rate = 0,
SaleCount = 0, SaleCount = 0,
ViewCount = 0, ViewCount = 0,
IsActive = true IsActive = request.IsActive
}; };
// آپلود تصویر اصلی (اگر فایل ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/DiscountProducts",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
product.ImagePath = result.Main.Path;
product.ThumbnailPath = result.Thumbnail.Path;
}
// آپلود بندانگشتی جداگانه (اختیاری)
if (request.ThumbnailFileBytes is { Length: > 0 })
{
var thumbResult = await _fileManager.UploadAsync(
"Images/DiscountProducts/Thumbnails",
request.ThumbnailFileBytes,
request.ThumbnailFileMime ?? "image/jpeg",
request.ThumbnailFileName,
cancellationToken);
product.ThumbnailPath = thumbResult.Path;
}
_context.DiscountProducts.Add(product); _context.DiscountProducts.Add(product);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
@@ -18,4 +18,9 @@ public class PlaceOrderResponseDto
public long TotalAmount { get; set; } public long TotalAmount { get; set; }
public long DiscountBalanceUsed { get; set; } public long DiscountBalanceUsed { get; set; }
public long GatewayAmountRequired { get; set; } public long GatewayAmountRequired { get; set; }
/// <summary>
/// URL درگاه پرداخت — اگر null باشد یعنی نیاز به پرداخت آنلاین نیست
/// </summary>
public string? PaymentUrl { get; set; }
} }
@@ -5,6 +5,8 @@ using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder; namespace CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder;
@@ -12,13 +14,22 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService; private readonly IInventoryService _inventoryService;
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<PlaceOrderCommandHandler> _logger;
public PlaceOrderCommandHandler( public PlaceOrderCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IInventoryService inventoryService) IInventoryService inventoryService,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<PlaceOrderCommandHandler> logger)
{ {
_context = context; _context = context;
_inventoryService = inventoryService; _inventoryService = inventoryService;
_paymentGateway = paymentGateway;
_configuration = configuration;
_logger = logger;
} }
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken) public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
@@ -98,19 +109,13 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
}); });
} }
// Validate discount balance usage // Always apply maximum possible discount (100%) — user cannot choose less
var maxDiscountBalanceUsable = totalDiscountAmount; var maxDiscountBalanceUsable = totalDiscountAmount;
var actualDiscountBalanceUsed = Math.Min(request.DiscountBalanceToUse, maxDiscountBalanceUsable); var actualDiscountBalanceUsed = Math.Min(maxDiscountBalanceUsable, userWallet.DiscountBalance);
actualDiscountBalanceUsed = Math.Min(actualDiscountBalanceUsed, userWallet.DiscountBalance);
if (actualDiscountBalanceUsed < request.DiscountBalanceToUse) _logger.LogInformation(
{ "Discount auto-applied: max allowed={MaxAllowed}, wallet balance={WalletBalance}, used={Used}",
return new PlaceOrderResponseDto maxDiscountBalanceUsable, userWallet.DiscountBalance, actualDiscountBalanceUsed);
{
Success = false,
Message = $"موجودی تخفیف کافی نیست. حداکثر قابل استفاده: {maxDiscountBalanceUsable:N0} تومان، موجودی شما: {userWallet.DiscountBalance:N0} تومان"
};
}
var gatewayAmountRequired = totalAmount - actualDiscountBalanceUsed; var gatewayAmountRequired = totalAmount - actualDiscountBalanceUsed;
@@ -172,15 +177,121 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// اگر مبلغ درگاه > ۰ باشد، باید به درگاه پرداخت متصل شویم
string? paymentUrl = null;
if (finalGatewayAmount > 0)
{
try
{
// آدرس callback — زرین‌پال بعد از پرداخت کاربر را به اینجا هدایت می‌کند
var cmsBaseUrl = _configuration["CmsBaseUrl"] ?? "https://localhost:32846";
var callbackUrl = $"{cmsBaseUrl}/api/payment/discount-order/callback?orderId={order.Id}";
// درخواست به درگاه
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
{
Amount = finalGatewayAmount,
UserId = request.UserId,
Description = $"فروشگاه تخفیفی - سفارش #{order.Id}",
CallbackUrl = callbackUrl
}, cancellationToken);
if (paymentResult.IsSuccess && !string.IsNullOrEmpty(paymentResult.GatewayUrl))
{
// ذخیره Authority/RefId در تراکنش برای verify بعدی
transaction.RefId = paymentResult.RefId;
// ثبت PaymentTransaction — جدول جدید با اطلاعات درگاه
var paymentTx = new PaymentTransaction
{
GatewayProvider = _configuration["PaymentProvider"] ?? "zarinpal",
MerchantId = _configuration["ZarinPal:MerchantId"] ?? "",
Amount = finalGatewayAmount,
CallbackUrl = callbackUrl,
Description = $"فروشگاه تخفیفی - سفارش #{order.Id}",
Mobile = null,
UserId = request.UserId,
RequestStatusCode = 100,
RequestStatusMessage = "Success",
Authority = paymentResult.RefId,
PaymentStatus = false, // هنوز verify نشده
TransactionId = transaction.Id,
OrderId = order.Id.ToString()
};
_context.PaymentTransactions.Add(paymentTx);
await _context.SaveChangesAsync(cancellationToken);
paymentUrl = paymentResult.GatewayUrl;
_logger.LogInformation(
"Payment gateway initiated for DiscountOrder #{OrderId}: RefId={RefId}, Url={Url}",
order.Id, paymentResult.RefId, paymentResult.GatewayUrl);
}
else
{
_logger.LogError(
"Payment gateway initiation failed for DiscountOrder #{OrderId}: {Error}",
order.Id, paymentResult.ErrorMessage);
return new PlaceOrderResponseDto
{
Success = false,
Message = $"خطا در اتصال به درگاه پرداخت: {paymentResult.ErrorMessage}",
OrderId = order.Id
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Payment gateway exception for DiscountOrder #{OrderId}", order.Id);
return new PlaceOrderResponseDto
{
Success = false,
Message = $"خطا در اتصال به درگاه پرداخت: {ex.Message}",
OrderId = order.Id
};
}
}
else
{
// اگر کل مبلغ از کیف تخفیفی پرداخت شد — مستقیماً تکمیل شود
transaction.PaymentStatus = PaymentStatus.Success;
transaction.PaymentDate = DateTime.Now;
order.PaymentStatus = PaymentStatus.Success;
order.PaymentDate = DateTime.Now;
order.DeliveryStatus = DeliveryStatus.Pending;
var walletForDeduct = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
if (walletForDeduct != null)
walletForDeduct.DiscountBalance -= actualDiscountBalanceUsed;
foreach (var cartItem in cartItems)
{
await _inventoryService.ConfirmSaleAsync(
cartItem.ProductId, ProductType.DiscountProduct,
cartItem.Count, order.Id, cancellationToken);
cartItem.Product.SaleCount += cartItem.Count;
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"DiscountOrder #{OrderId} fully paid via discount balance ({Amount} T)",
order.Id, actualDiscountBalanceUsed);
}
return new PlaceOrderResponseDto return new PlaceOrderResponseDto
{ {
Success = true, Success = true,
Message = "سفارش ایجاد شد. لطفا پرداخت را تکمیل کنید", Message = finalGatewayAmount > 0
? "سفارش ایجاد شد. در حال انتقال به درگاه پرداخت..."
: "سفارش با موفقیت ثبت و پرداخت شد",
OrderId = order.Id, OrderId = order.Id,
TransactionId = transaction.Id, TransactionId = transaction.Id,
TotalAmount = totalAmount, TotalAmount = totalAmount,
DiscountBalanceUsed = actualDiscountBalanceUsed, DiscountBalanceUsed = actualDiscountBalanceUsed,
GatewayAmountRequired = finalGatewayAmount GatewayAmountRequired = finalGatewayAmount,
PaymentUrl = paymentUrl
}; };
} }
} }
@@ -12,7 +12,16 @@ public class UpdateDiscountProductCommand : IRequest<Unit>
public int MaxDiscountPercent { get; set; } public int MaxDiscountPercent { get; set; }
public string ImagePath { get; set; } public string ImagePath { get; set; }
public string ThumbnailPath { get; set; } public string ThumbnailPath { get; set; }
public int SortOrder { get; set; }
public int RemainingCount { get; set; } public int RemainingCount { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public List<long> CategoryIds { get; set; } = new(); public List<long> CategoryIds { get; set; } = new();
// Image file upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
public byte[]? ThumbnailFileBytes { get; set; }
public string? ThumbnailFileMime { get; set; }
public string? ThumbnailFileName { get; set; }
} }
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.DiscountShop; using CMSMicroservice.Domain.Entities.DiscountShop;
using MediatR; using MediatR;
@@ -8,10 +9,12 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateDiscountProd
public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscountProductCommand, Unit> public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscountProductCommand, Unit>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public UpdateDiscountProductCommandHandler(IApplicationDbContext context) public UpdateDiscountProductCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{ {
_context = context; _context = context;
_fileManager = fileManager;
} }
public async Task<Unit> Handle(UpdateDiscountProductCommand request, CancellationToken cancellationToken) public async Task<Unit> Handle(UpdateDiscountProductCommand request, CancellationToken cancellationToken)
@@ -27,11 +30,44 @@ public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscoun
product.FullInformation = request.FullInformation; product.FullInformation = request.FullInformation;
product.Price = request.Price; product.Price = request.Price;
product.MaxDiscountPercent = request.MaxDiscountPercent; product.MaxDiscountPercent = request.MaxDiscountPercent;
product.ImagePath = request.ImagePath;
product.ThumbnailPath = request.ThumbnailPath;
product.RemainingCount = request.RemainingCount; product.RemainingCount = request.RemainingCount;
product.IsActive = request.IsActive; product.IsActive = request.IsActive;
// آپلود تصویر اصلی (اگر فایل جدید ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/DiscountProducts",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
product.ImagePath = result.Main.Path;
product.ThumbnailPath = result.Thumbnail.Path;
}
else if (!string.IsNullOrEmpty(request.ImagePath))
{
product.ImagePath = request.ImagePath;
}
// آپلود بندانگشتی جداگانه (اختیاری)
if (request.ThumbnailFileBytes is { Length: > 0 })
{
var thumbResult = await _fileManager.UploadAsync(
"Images/DiscountProducts/Thumbnails",
request.ThumbnailFileBytes,
request.ThumbnailFileMime ?? "image/jpeg",
request.ThumbnailFileName,
cancellationToken);
product.ThumbnailPath = thumbResult.Path;
}
else if (!string.IsNullOrEmpty(request.ThumbnailPath))
{
product.ThumbnailPath = request.ThumbnailPath;
}
// Update categories // Update categories
var existingCategories = await _context.DiscountProductCategories var existingCategories = await _context.DiscountProductCategories
.Where(pc => pc.ProductId == request.ProductId) .Where(pc => pc.ProductId == request.ProductId)
@@ -43,4 +43,6 @@ public class OrderItemDto
public int DiscountPercentUsed { get; set; } public int DiscountPercentUsed { get; set; }
public long DiscountAmount { get; set; } public long DiscountAmount { get; set; }
public long FinalPrice { get; set; } public long FinalPrice { get; set; }
public string ImagePath { get; set; }
public string ThumbnailPath { get; set; }
} }
@@ -53,7 +53,9 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
UnitPrice = od.UnitPrice, UnitPrice = od.UnitPrice,
DiscountPercentUsed = od.DiscountPercentUsed, DiscountPercentUsed = od.DiscountPercentUsed,
DiscountAmount = od.DiscountAmount, DiscountAmount = od.DiscountAmount,
FinalPrice = od.FinalPrice FinalPrice = od.FinalPrice,
ImagePath = od.Product.ImagePath ?? "",
ThumbnailPath = od.Product.ThumbnailPath ?? ""
}).ToList() }).ToList()
}; };
} }
@@ -0,0 +1,36 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.DeletePageImage;
// ── Command ──
public class DeletePageImageCommand : IRequest<Unit>
{
public long Id { get; set; }
}
// ── Handler ──
public class DeletePageImageCommandHandler : IRequestHandler<DeletePageImageCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeletePageImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(DeletePageImageCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SitePageImages
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
if (entity != null)
{
entity.IsDeleted = true; // Soft delete
await _context.SaveChangesAsync(cancellationToken);
}
return Unit.Value;
}
}
@@ -0,0 +1,96 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageImage;
// ── Command ──
public class SavePageImageCommand : IRequest<SavePageImageResult>
{
public long Id { get; set; } // 0 = ایجاد جدید
public long SitePageSettingsId { get; set; }
public string ImageGroup { get; set; } = default!;
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Description { get; set; }
public string? LinkUrl { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
// Image upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
public class SavePageImageResult
{
public long Id { get; set; }
public bool Success { get; set; }
}
// ── Handler ──
public class SavePageImageCommandHandler : IRequestHandler<SavePageImageCommand, SavePageImageResult>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public SavePageImageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<SavePageImageResult> Handle(SavePageImageCommand request, CancellationToken cancellationToken)
{
SitePageImage entity;
if (request.Id > 0)
{
// ویرایش
entity = await _context.SitePageImages
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
?? throw new Exception($"تصویر با شناسه {request.Id} یافت نشد");
}
else
{
// ایجاد جدید
entity = new SitePageImage
{
SitePageSettingsId = request.SitePageSettingsId,
ImageGroup = request.ImageGroup,
ImagePath = string.Empty // placeholder — will be set below
};
_context.SitePageImages.Add(entity);
}
entity.Title = request.Title;
entity.Subtitle = request.Subtitle;
entity.Description = request.Description;
entity.LinkUrl = request.LinkUrl;
entity.IconName = request.IconName;
entity.SortOrder = request.SortOrder;
entity.IsActive = request.IsActive;
// آپلود تصویر
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
$"Images/SitePageSettings/{request.ImageGroup}",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
entity.ImagePath = result.Main.Path;
entity.ThumbnailPath = result.Thumbnail.Path;
}
await _context.SaveChangesAsync(cancellationToken);
return new SavePageImageResult { Id = entity.Id, Success = true };
}
}
@@ -0,0 +1,83 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageSettings;
// ── Command ──
public class SavePageSettingsCommand : IRequest<SavePageSettingsResult>
{
public string PageKey { get; set; } = default!;
public string Title { get; set; } = default!;
public string? MetaDescription { get; set; }
public string? HeroTitle { get; set; }
public string? HeroSubtitle { get; set; }
public bool IsActive { get; set; }
public string? SettingsJson { get; set; }
// Hero image upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
public class SavePageSettingsResult
{
public long Id { get; set; }
public bool Success { get; set; }
}
// ── Handler ──
public class SavePageSettingsCommandHandler : IRequestHandler<SavePageSettingsCommand, SavePageSettingsResult>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public SavePageSettingsCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<SavePageSettingsResult> Handle(SavePageSettingsCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SitePageSettingsEntities
.FirstOrDefaultAsync(x => x.PageKey == request.PageKey && !x.IsDeleted, cancellationToken);
if (entity == null)
{
// ایجاد صفحه جدید (فقط برای seed اولیه — صفحات ثابت هستند)
entity = new SitePageSettings
{
PageKey = request.PageKey,
};
_context.SitePageSettingsEntities.Add(entity);
}
entity.Title = request.Title;
entity.MetaDescription = request.MetaDescription;
entity.HeroTitle = request.HeroTitle;
entity.HeroSubtitle = request.HeroSubtitle;
entity.IsActive = request.IsActive;
entity.SettingsJson = request.SettingsJson;
// آپلود تصویر هیرو
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/SitePageSettings",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
entity.HeroImagePath = result.Main.Path;
}
await _context.SaveChangesAsync(cancellationToken);
return new SavePageSettingsResult { Id = entity.Id, Success = true };
}
}
@@ -0,0 +1,30 @@
using FluentValidation;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageSettings;
public class SavePageSettingsCommandValidator : AbstractValidator<SavePageSettingsCommand>
{
private static readonly string[] ValidPageKeys = { "landing", "about", "contact", "licenses" };
public SavePageSettingsCommandValidator()
{
RuleFor(x => x.PageKey)
.NotEmpty().WithMessage("کلید صفحه الزامی است")
.MaximumLength(50)
.Must(key => ValidPageKeys.Contains(key))
.WithMessage("کلید صفحه باید یکی از مقادیر landing, about, contact, licenses باشد");
RuleFor(x => x.Title)
.NotEmpty().WithMessage("عنوان صفحه الزامی است")
.MaximumLength(200).WithMessage("عنوان صفحه حداکثر ۲۰۰ کاراکتر");
RuleFor(x => x.MetaDescription)
.MaximumLength(300).WithMessage("توضیحات متا حداکثر ۳۰۰ کاراکتر");
RuleFor(x => x.HeroTitle)
.MaximumLength(200).WithMessage("عنوان هیرو حداکثر ۲۰۰ کاراکتر");
RuleFor(x => x.HeroSubtitle)
.MaximumLength(500).WithMessage("زیرعنوان هیرو حداکثر ۵۰۰ کاراکتر");
}
}
@@ -0,0 +1,49 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Queries.GetAllPageSettings;
// ── Query ──
public class GetAllPageSettingsQuery : IRequest<List<PageSettingsSummaryDto>>
{
}
// ── Handler ──
public class GetAllPageSettingsQueryHandler : IRequestHandler<GetAllPageSettingsQuery, List<PageSettingsSummaryDto>>
{
private readonly IApplicationDbContext _context;
public GetAllPageSettingsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<PageSettingsSummaryDto>> Handle(GetAllPageSettingsQuery request, CancellationToken cancellationToken)
{
return await _context.SitePageSettingsEntities
.Where(x => !x.IsDeleted)
.OrderBy(x => x.Id)
.Select(x => new PageSettingsSummaryDto
{
Id = x.Id,
PageKey = x.PageKey,
Title = x.Title,
IsActive = x.IsActive,
ImageCount = x.Images.Count(i => !i.IsDeleted),
LastModified = x.LastModified ?? x.Created
})
.ToListAsync(cancellationToken);
}
}
// ── DTO ──
public class PageSettingsSummaryDto
{
public long Id { get; set; }
public string PageKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public bool IsActive { get; set; }
public int ImageCount { get; set; }
public DateTime LastModified { get; set; }
}
@@ -0,0 +1,92 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Queries.GetPageSettings;
// ── Query ──
public class GetPageSettingsQuery : IRequest<PageSettingsDto?>
{
public string PageKey { get; set; } = default!;
}
// ── Handler ──
public class GetPageSettingsQueryHandler : IRequestHandler<GetPageSettingsQuery, PageSettingsDto?>
{
private readonly IApplicationDbContext _context;
public GetPageSettingsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<PageSettingsDto?> Handle(GetPageSettingsQuery request, CancellationToken cancellationToken)
{
var entity = await _context.SitePageSettingsEntities
.Include(x => x.Images.Where(i => !i.IsDeleted && i.IsActive).OrderBy(i => i.SortOrder))
.FirstOrDefaultAsync(x => x.PageKey == request.PageKey && !x.IsDeleted, cancellationToken);
if (entity == null)
return null;
return MapToDto(entity);
}
public static PageSettingsDto MapToDto(SitePageSettings entity) => new()
{
Id = entity.Id,
PageKey = entity.PageKey,
Title = entity.Title,
MetaDescription = entity.MetaDescription,
HeroTitle = entity.HeroTitle,
HeroSubtitle = entity.HeroSubtitle,
HeroImagePath = entity.HeroImagePath,
IsActive = entity.IsActive,
SettingsJson = entity.SettingsJson,
Images = entity.Images.Select(i => new PageImageDto
{
Id = i.Id,
ImageGroup = i.ImageGroup,
Title = i.Title,
Subtitle = i.Subtitle,
Description = i.Description,
ImagePath = i.ImagePath,
ThumbnailPath = i.ThumbnailPath,
LinkUrl = i.LinkUrl,
IconName = i.IconName,
SortOrder = i.SortOrder,
IsActive = i.IsActive
}).ToList()
};
}
// ── DTOs ──
public class PageSettingsDto
{
public long Id { get; set; }
public string PageKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? MetaDescription { get; set; }
public string? HeroTitle { get; set; }
public string? HeroSubtitle { get; set; }
public string? HeroImagePath { get; set; }
public bool IsActive { get; set; }
public string? SettingsJson { get; set; }
public List<PageImageDto> Images { get; set; } = new();
}
public class PageImageDto
{
public long Id { get; set; }
public string ImageGroup { get; set; } = string.Empty;
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Description { get; set; }
public string ImagePath { get; set; } = string.Empty;
public string? ThumbnailPath { get; set; }
public string? LinkUrl { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
@@ -1,7 +1,7 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory; namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory;
/// <summary> /// <summary>
/// Command برای کم کردن موجودی (فروش) /// Command برای کم کردن موجودی (فروش، خسارت، مفقودی و ...)
/// </summary> /// </summary>
public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto> public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto>
{ {
@@ -19,4 +19,8 @@ public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto>
public long? PerformedByUserId { get; init; } public long? PerformedByUserId { get; init; }
/// <summary>آیا از موجودی رزرو شده کم شود؟</summary> /// <summary>آیا از موجودی رزرو شده کم شود؟</summary>
public bool FromReserved { get; init; } = true; public bool FromReserved { get; init; } = true;
/// <summary>نوع حرکت انبار (پیش‌فرض: فروش)</summary>
public Domain.Enums.StockMovementType MovementType { get; init; } = Domain.Enums.StockMovementType.Sale;
/// <summary>توضیحات</summary>
public string? Note { get; init; }
} }
@@ -53,11 +53,11 @@ public class ReduceInventoryCommandHandler : IRequestHandler<ReduceInventoryComm
var stockMovement = new StockMovement var stockMovement = new StockMovement
{ {
InventoryItemId = item.Id, InventoryItemId = item.Id,
MovementType = StockMovementType.Sale, MovementType = request.MovementType,
Quantity = request.Quantity, Quantity = -request.Quantity, // منفی — CHECK constraint: QuantityAfter = QuantityBefore + Quantity
QuantityBefore = previousQuantity, QuantityBefore = previousQuantity,
QuantityAfter = item.Quantity, QuantityAfter = item.Quantity,
Note = "Sale confirmed", Note = request.Note ?? request.MovementType.ToString(),
ReferenceNumber = request.ReferenceNumber ?? $"SALE-{DateTime.UtcNow:yyyyMMddHHmmss}", ReferenceNumber = request.ReferenceNumber ?? $"SALE-{DateTime.UtcNow:yyyyMMddHHmmss}",
OrderId = request.OrderId, OrderId = request.OrderId,
DiscountOrderId = request.DiscountOrderId, DiscountOrderId = request.DiscountOrderId,
@@ -3,7 +3,7 @@ namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkStat
public class GetNetworkStatisticsQuery : IRequest<GetNetworkStatisticsResponseDto> public class GetNetworkStatisticsQuery : IRequest<GetNetworkStatisticsResponseDto>
{ {
/// <summary> /// <summary>
/// شناسه کاربر برای محاسبه آمار شبکه او - 0 یا null یعنی کاربر جاری /// شناسه کاربر برای محاسبه آمار شبکه او - 0 یعنی آمار کل شبکه (root user)
/// </summary> /// </summary>
public long UserId { get; set; } public long UserId { get; set; }
} }
@@ -5,30 +5,33 @@ namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkStat
public class GetNetworkStatisticsQueryHandler : IRequestHandler<GetNetworkStatisticsQuery, GetNetworkStatisticsResponseDto> public class GetNetworkStatisticsQueryHandler : IRequestHandler<GetNetworkStatisticsQuery, GetNetworkStatisticsResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public GetNetworkStatisticsQueryHandler( public GetNetworkStatisticsQueryHandler(
IApplicationDbContext context, IApplicationDbContext context)
ICurrentUserService currentUser)
{ {
_context = context; _context = context;
_currentUser = currentUser;
} }
public async Task<GetNetworkStatisticsResponseDto> Handle(GetNetworkStatisticsQuery request, CancellationToken cancellationToken) public async Task<GetNetworkStatisticsResponseDto> Handle(GetNetworkStatisticsQuery request, CancellationToken cancellationToken)
{ {
// Get userId - use current user if not specified or is 0 // UserId > 0 → stats for that user's network
var userId = request.UserId == 0 // UserId == 0 → global stats (find root user of the network)
? (long.TryParse(_currentUser.UserId, out var currentUserId) ? currentUserId : 0) var userId = request.UserId;
: request.UserId;
// Get all users (needed for descendant calculation anyway)
var allUsers = await _context.Users.ToListAsync(cancellationToken);
if (userId == 0) if (userId == 0)
{ {
throw new UnauthorizedAccessException("User ID not found"); // Find root user (user with no NetworkParentId)
var rootUser = allUsers.FirstOrDefault(x => x.NetworkParentId == null || x.NetworkParentId == 0);
if (rootUser != null)
userId = rootUser.Id;
else if (allUsers.Count > 0)
userId = allUsers.First().Id;
else
throw new InvalidOperationException("No users found in the system");
} }
// Get all descendants recursively
var allUsers = await _context.Users.ToListAsync(cancellationToken);
var allDescendants = GetAllDescendants(userId, allUsers); var allDescendants = GetAllDescendants(userId, allUsers);
// Statistics for the user's network (all descendants) // Statistics for the user's network (all descendants)
@@ -8,28 +8,23 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ILogger<GetNetworkTreeQueryHandler> _logger; private readonly ILogger<GetNetworkTreeQueryHandler> _logger;
private readonly ICurrentUserService _currentUser;
public GetNetworkTreeQueryHandler( public GetNetworkTreeQueryHandler(
IApplicationDbContext context, IApplicationDbContext context,
ILogger<GetNetworkTreeQueryHandler> logger, ILogger<GetNetworkTreeQueryHandler> logger)
ICurrentUserService currentUser)
{ {
_context = context; _context = context;
_logger = logger; _logger = logger;
_currentUser = currentUser;
} }
public async Task<NetworkTreeDto?> Handle(GetNetworkTreeQuery request, CancellationToken cancellationToken) public async Task<NetworkTreeDto?> Handle(GetNetworkTreeQuery request, CancellationToken cancellationToken)
{ {
// Get userId - use current user if UserId is 0 // UserId must be provided - customer endpoints resolve from JWT before calling
var userId = request.UserId == 0 var userId = request.UserId;
? (long.TryParse(_currentUser.UserId, out var currentUserId) ? currentUserId : 0)
: request.UserId;
if (userId == 0) if (userId == 0)
{ {
throw new UnauthorizedAccessException("User ID not found"); throw new ArgumentException("UserId is required for network tree query");
} }
// Create a new request with the resolved userId // Create a new request with the resolved userId
@@ -5,5 +5,8 @@ public record CreateNewOtpTokenCommand : IRequest<CreateNewOtpTokenResponseDto>
public string Mobile { get; init; } public string Mobile { get; init; }
//مقصود //مقصود
public string Purpose { get; init; } public string Purpose { get; init; }
/// <summary>
/// شناسه GUID قرارداد (فقط برای purpose=signcontract/signclubcontract)
/// </summary>
public string? SignGuid { get; init; }
} }
@@ -57,7 +57,7 @@ public class CreateNewOtpTokenCommandHandler : IRequestHandler<CreateNewOtpToken
}; };
await _context.OtpTokens.AddAsync(entity, cancellationToken); await _context.OtpTokens.AddAsync(entity, cancellationToken);
entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity, code)); entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity, code, request.SignGuid));
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return new CreateNewOtpTokenResponseDto() return new CreateNewOtpTokenResponseDto()
{ {

Some files were not shown because too many files have changed in this diff Show More