70 Commits

Author SHA1 Message Date
masoodafar-web dcd11351a3 feat(T4.13): add PackageFeature CRUD support
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m41s
- Proto: add feature_ids to Create/Update/Get package messages
- Commands: add FeatureIds to Create/Update commands
- Handlers: sync PackageFeature join entities on create/update
- Queries: include PackageFeatures, map FeatureIds in responses
- Bump NuGet to v0.0.188
2026-02-26 21:59:24 +03:30
masoodafar-web aaaf7fc1ca feat(commission): add per-package filtering to commission & balance queries
- Proto: add package_id filter to 4 request messages, package_id/package_title to 4 response models
- Queries: add PackageId filter to GetUserWeeklyBalances, GetUserCommissionPayouts, GetMyCommissionPayouts, GetMyWeeklyBalances
- Handlers: include Package navigation, filter by PackageId, map PackageId/PackageTitle
- DTOs: add PackageId + PackageTitle to all response models
- Mappings: update CommissionProfile with PackageId/PackageTitle for all admin+customer mappings
- NuGet: bump proto version to 0.0.187
2026-02-26 21:07:59 +03:30
masoodafar-web 7554d7054a Phase 8d: Proto cleanup — delete 4 deprecated RPCs + 8 deprecated messages
- Deleted RPCs: PurchaseGoldenPackage, VerifyGoldenPackagePurchase,
  InitiateBasePackagePayment, VerifyBasePackagePayment
- Deleted messages: PurchaseGoldenPackage{Request,Response},
  VerifyGoldenPackagePurchase{Request,Response},
  InitiateBasePackagePayment{Request,Response},
  VerifyBasePackagePayment{Request,Response}
- Kept: GetUserPackageStatus (still in use)
- NuGet version bump: 0.0.185 → 0.0.186
2026-02-26 18:00:40 +03:30
masoodafar-web ce8e248843 Phase 8b: Bump Protobuf NuGet to v0.0.185 2026-02-26 17:42:59 +03:30
masoodafar-web 8446e0e3b5 Phase 7c: Delete deprecated handlers + clean service/mapping refs
- Delete 4 deprecated CQRS handler folders (12 files):
  - PurchaseGoldenPackage/ (Command, Handler, Validator)
  - VerifyGoldenPackagePurchase/ (Command, Handler, Validator)
  - InitiateBasePackagePayment/ (Command, Handler, Validator)
  - VerifyBasePackagePayment/ (Command, Handler, Validator)
- PackageService.cs: Remove 4 gRPC override methods that dispatched
  to deleted handlers (proto RPCs now auto-throw Unimplemented)
- PackageProfile.cs: Remove all Mapster mappings for deleted types
  (6 mapping blocks + 4 using directives removed)
- Customer* RPCs remain fully functional
- Build: 0 errors
2026-02-26 03:02:17 +03:30
masoodafar-web 161f796cd4 Phase 7b: Embed orderId in callback URL for CustomerPurchasePackage
- CustomerPurchasePackage: append orderId to callback URL before sending to gateway
- Ensures PaymentCallback page receives orderId via query string for verification
2026-02-26 02:34:47 +03:30
masoodafar-web 469d97bb60 Phase 7a: Cosmetic cleanup + delete orphaned PurchasePackage handler
- Delete orphaned PurchasePackageCommand handler (no RPC dispatch, no callers)
- Fix doc-comments: طلایی → پکیج in enums, entities, handlers
- Remove stale 56M inline comments
- Clean SystemConstants deprecated field docs
2026-02-26 02:01:33 +03:30
masoodafar-web d19c569aae Phase 6: Deprecation cleanup + ConfigurationService MagicWallet
- Mark PurchaseGoldenPackage/VerifyGoldenPackagePurchase RPCs as deprecated
- Mark InitiateBasePackagePayment/VerifyBasePackagePayment RPCs as deprecated
- Remove deprecated SystemConstants from GetAllAsDict/GetAllWithDescriptions helpers
- Add MagicWallet per-package values to ConfigurationService (Multiplier, MaxDeposit, MaxCredit)
- All deprecated constants have zero active code usages — safe dead code
2026-02-26 01:10:43 +03:30
masoodafar-web 7176fe44ee Fix SP: cm.PackageId → cm.LastPackageId (match actual DB column name) 2026-02-26 00:59:59 +03:30
masoodafar-web 607f791b65 Phase 5: Per-package commission + golden ref cleanup
- Fix golden/طلایی string refs in user-facing messages (ActivateClubMembership)
- Rename HasPurchasedGoldenPackage → HasPurchasedPackage (DTO, Handler, Proto, Mapping)
- ORM commission: per-user-package calculation via ClubMembership.LastPackageId
  - Build userPackageMap, per-user maxBalancesPerLeg/maxNetworkLevel
  - Carryover keyed by (UserId, PackageId) tuple
- SP commission: loop over packages, pass @PackageId/@InputMaxBalancesPerLeg/@InputMaxNetworkLevel
- sp_CalculateWeeklyBalances: accept 3 new params, filter by PackageId, insert PackageId column
2026-02-26 00:57:20 +03:30
masoodafar-web 0002a5a6f2 Phase 4: Complete Package CRUD DTOs + Legacy Fixes
- CreateNewPackageCommand: Add 12 new Package fields (SortOrder, IsActive, IsBasePackage,
  SupportsDayaPurchase, SupportsDirectPurchase, ActivationFee, DiscountMultiplier,
  MagicWalletMultiplier, MaxBalancesPerLeg, MaxNetworkLevel, MagicWalletMaxDeposit,
  MagicWalletMaxCredit) with sensible defaults
- GetPackageResponseDto: Add 12 new fields (Mapster auto-maps via ProjectToType)
- GetAllPackageByFilterResponseModel: Add 12 new fields (Mapster auto-maps)
- PurchaseGoldenPackage: Replace fragile Title string match ('طلایی'/'golden')
  with proper IsDeleted/IsActive/SupportsDirectPurchase validation
- VerifyPackagePurchase: Replace hardcoded order.Amount*2 with
  package.DiscountMultiplier from DB (fallback 2.0 for null Package)
2026-02-26 00:24:35 +03:30
masoodafar-web ccb938e9ba Phase 3: Package layer overhaul — fix critical bugs + proto enhancement
Proto (package.proto):
- Add 11 new Package fields to Create/Update/Get/GetAll messages
- Add 8 new fields to CustomerPackageModel for frontend

Critical Bug Fixes:
- VerifyGoldenPackagePurchase: Replace hardcoded ×2 with package.DiscountMultiplier from DB

Query Fixes:
- GetAllPackageByFilter: Add IsDeleted filter (was returning soft-deleted packages)
- GetCustomerPackages: Apply IsDeleted+IncludeInactive filters, sort by SortOrder, return new fields
- GetCustomerPackageDetails: Load PackageFeatures from DB (was hardcoded), add IsDeleted filter, return new fields
- GetCustomerPurchaseHistory: Include Transaction (was null → ReferenceCode always empty)

UpdatePackage: Add 12 new fields (SortOrder, IsActive, IsBasePackage, etc.)

Build: 0 errors
2026-02-25 23:50:09 +03:30
masoodafar-web 8e5c7c5205 Phase 2: Replace all deprecated SystemConstants with Package entity reads
- ActivateClubMembership: Load Package, set First/Last fields, PackageId on Cycle+Pool
- AcceptClubMembershipContract: Load Package, set First/Last fields, PackageId on Pool
- CalculateWeeklyBalances: Load base package, use package.MaxBalancesPerLeg/MaxNetworkLevel, set PackageId on NetworkWeeklyBalance
- ProcessUserPayouts: Load base package, use package.MaxNetworkLevel, set PackageId on UserCommissionPayout
- CheckAndProcessDayaLoans: Load base package (IsBasePackage), replace DayaLoanAmount→package.Price, fix hardcoded p.Id==4
- CreateManualPayment: Load base package, replace BasePackageAmount→package.Price, use DiscountMultiplier
- InitiateBasePackagePayment: Remove hardcoded BasePackageId=4, load from IsBasePackage, use package.Price/Id
- VerifyBasePackagePayment: Load Package from order.PackageId, use package.Price+DiscountMultiplier
- ChargeMagicWallet: Load user's package from current cycle, use package.MagicWalletMaxDeposit
- VerifyMagicWalletCharge: Load user's package, use package.MagicWalletMultiplier/MagicWalletMaxDeposit
- OrmCommissionCalculationStrategy: Load base package in both methods, set PackageId on Balance+Payout
- ConfigurationService: Inject IApplicationDbContext, read package values from DB instead of SystemConstants
- UserOrderService: Load user's package for EXIT trigger MagicWalletMaxDeposit check
- UserWalletService: Load user's package for GetMagicWalletStatus display

All 9 deprecated SystemConstants now have ZERO usages outside SystemConstants.cs
Build: 0 errors, 521 warnings (all pre-existing)
2026-02-25 23:25:18 +03:30
masoodafar-web a9cd2fd67a Phase 1.5: EF Migration + Data Seed + Backfill
MIGRATION (AddPackageBasedSystem):
- Adds 11 new columns to Packages table
- Creates PackageFeatures table (Package×ClubFeature junction)
- Adds PackageId FK to: ClubMembershipCycles, WeeklyCommissionPools,
  UserCommissionPayouts, NetworkWeeklyBalances, UserWalletChangeLogs
- Adds First/LastActivationDate, First/LastPackageId to ClubMemberships
- Drops old unique indexes, creates new composite unique indexes:
  (WeekDef,Package), (User,WeekDef,Package)

DATA MIGRATION (embedded in EF migration):
1. Seeds Golden Package (Id=1): Price=56M, ActivationFee=25.2M,
   DiscountMultiplier=2.0, MagicWalletMultiplier=2.5, MaxBalancesPerLeg=300,
   MaxNetworkLevel=15, MagicWalletMaxDeposit=1B, MagicWalletMaxCredit=2.5B
2. Backfills PackageId=1 on all existing commission/network records
3. Backfills ClubMembership First/Last fields from existing ActivatedAt

FIXES:
- ClubMembership First/Last fields now nullable (DateTime?, long?)
  for backward compat with existing records
- UserPackagePurchaseConfiguration: .WithMany() → .WithMany(p => p.Purchases)
  to prevent shadow FK PackageId1

Build: 0 errors, 73 warnings
2026-02-25 22:49:12 +03:30
masoodafar-web ae92ab8697 Phase 1: Package-Based Domain Infrastructure
ENTITIES:
- Package: +11 fields (SortOrder, IsActive, IsBasePackage, SupportsDaya/Direct,
  ActivationFee, DiscountMultiplier, MagicWalletMultiplier, MaxBalancesPerLeg,
  MaxNetworkLevel, MagicWalletMaxDeposit/Credit) + nav props
- PackageFeature: NEW junction entity (Package × ClubFeature) for feature DIFF
- ClubMembership: +4 fields (FirstActivationDate/PackageId, LastActivation/PackageId)
  ActivatedAt kept as [Obsolete] for backward compat
- ClubMembershipCycle: +PackageId FK (tracks which package per cycle)
- WeeklyCommissionPool: +PackageId FK (separate pool per package)
- UserCommissionPayout: +PackageId FK (payout per package)
- NetworkWeeklyBalance: +PackageId FK (balance per package)
- UserWalletChangeLog: +PackageId? FK (optional package tracking)

EF CONFIGS:
- PackageConfiguration: 11 new field configs + indexes
- PackageFeatureConfiguration: NEW (unique PackageId+ClubFeatureId)
- ClubMembershipConfiguration: First/Last Package FKs + LastActivationDate index
- ClubMembershipCycleConfiguration: Package FK
- WeeklyCommissionPoolConfiguration: Package FK + unique(WeekDef,Package)
- UserCommissionPayoutConfiguration: Package FK + unique(User,WeekDef,Package)
- NetworkWeeklyBalanceConfiguration: Package FK + unique(User,WeekDef,Package)
- UserWalletChangeLogConfiguration: Package FK (nullable)

DBCONTEXT:
- DbSet<PackageFeature> added to ApplicationDbContext + IApplicationDbContext

SYSTEMCONSTANTS:
- 9 constants marked [Obsolete] → moved to Package entity
  (ClubMembershipGiftValue, ClubActivationFee, BasePackageAmount, DayaLoanAmount,
   MagicWalletMultiplier, MagicWalletMaxDeposit, MagicWalletMaxCredit,
   CommissionMaxWeeklyBalancesPerLeg, CommissionMaxNetworkLevel)

Build: 0 errors, 610 warnings (40 new = expected Obsolete CS0618)
2026-02-25 22:32:42 +03:30
masoodafar-web fe3edd178d fix(B6): EXIT Magic Mode — reset PackagePurchaseMethod + close current cycle
B6: PackagePurchaseMethod was NOT reset when exiting Magic Mode.
This caused Guards G1-G3 to permanently block re-purchase.

Changes:
- user.PackagePurchaseMethod = None (allows re-purchase via G1-G3)
- currentCycle.IsCurrentCycle = false (ready for new cycle)

Note: membership.IsActive is intentionally preserved (Q19 — contract is one-time).
Features are preserved — DIFF will be applied on next purchase (Q20).
2026-02-25 21:59:30 +03:30
masoodafar-web 13dd0f552f refactor: archive 12 dead gRPC RPCs with [ARCHIVED] markers
Archived RPCs (code preserved, marked obsolete):
- fms.proto: excluded from csproj (CreateNewFileInfo, DeleteFileInfo)
- UserOrderService: GetOrdersByDateRange, CreateNewOrderForCustomer, SubmitOrderForCustomer
- ConfigurationService: DeactivateConfiguration, GetConfigurationHistory
- CityService: UpdateCity, DeleteCity
- HealthService: GetServiceHealth
- CategoryService: GetCategoryByIdForCustomer
- inventory.proto: BulkAdjustStock (no implementation existed)

All archived RPCs wrapped in #region [ARCHIVED] with [Obsolete] attributes.
No code deleted — archive only. Build passes with 0 errors.
2026-02-24 22:53:36 +03:30
masoodafar-web 8b9c317de6 fix: Phase 0 — critical bugs in package purchase handlers
B1: VerifyGoldenPackagePurchase — DiscountBalance was NOT charged (Amount × 2)
    Now charges DiscountBalance same as all other handlers.

B2: VerifyGoldenPackagePurchase — UserPackagePurchase record was NOT created
    Now creates UPP with OrderId and TransactionId.

B3: VerifyPackagePurchase — UserPackagePurchase record was NOT created
    Now creates UPP after wallet charge.

B4: VerifyBasePackagePayment — UserPackagePurchase record was NOT created
    Now creates UPP after wallet change log.

Also: VerifyGolden WalletChangeLog split into 2 logs (Balance + Discount)
      to match the pattern used by VerifyPackagePurchase.
      Transaction description changed from 'پکیج طلایی' to 'پکیج' (generic).

Impact: Only Daya Loan handler was creating UserPackagePurchase before.
        Now all 4 payment paths create it consistently.
2026-02-24 21:52:36 +03:30
masoodafar-web cbaa20f339 fix: remove double ×10 Rial conversion in ZarinPalPaymentService
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
Root cause: FrontOffice already converts Toman→Rial (×10) before sending
to CMS. ZarinPalPaymentService was multiplying by 10 AGAIN, causing
amounts to be 10x too large.

Example: user enters 500K Toman → FO sends 5M Rial → CMS did ×10 → 50M
Rial sent to ZarinPal → showed 5M Toman instead of 500K.

Changes:
- ZarinPalPaymentService.InitiatePaymentAsync: remove ×10 (amount already Rial)
- ZarinPalPaymentService.VerifyPaymentWithAmountAsync: remove ×10 + accept Rial
- ZarinPalPaymentService.VerifyResult.Amount: return Rial (no /10 conversion)
- VerifyMagicWalletChargeCommandHandler: remove /10 before calling Verify
- PaymentCallbackController: update comments (amount is Rial)
- Also includes: improved HTTP error logging for non-200 responses
- Also includes: production URL fix (kbs1→kbs2)
2026-02-24 00:49:16 +03:30
masoodafar-web e206b71186 fix: reduce discount order expiry from 30 to 15 minutes
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-24 00:24:02 +03:30
masoodafar-web 0457ef6c7c fix: validate discount wallet balance before applying discount in PlaceOrderCommandHandler
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m36s
2026-02-23 23:06:37 +03:30
masoodafar-web f3ac5ad7df fix: add missing UserWalletChangeLog for discount shop purchases and discount wallet charge
Bug: when buying from discount shop, DiscountBalance was deducted from DB
but no UserWalletChangeLog was created — making it invisible in wallet history.
Same issue existed for discount wallet top-up (charge).

Fixed in 3 handlers:
- PlaceOrderCommandHandler (fully paid by discount balance path)
- CompleteOrderPaymentCommandHandler (gateway + discount balance path)
- VerifyDiscountWalletChargeCommandHandler (discount wallet charge)
2026-02-23 22:59:00 +03:30
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
320 changed files with 46348 additions and 2843 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
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
for i in $(seq 1 90); do
@@ -50,14 +50,19 @@ jobs:
run: |
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
run: |
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 '
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
echo "📦 $proj"
dotnet restore "$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
@@ -72,18 +77,26 @@ jobs:
- name: Build Docker Image
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
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Deploy to Kubernetes
run: |
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 }} "
kubectl rollout restart deployment/cms
kubectl rollout status deployment/cms --timeout=180s
kubectl apply -f /tmp/cms-config.yaml &&
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!"
+61 -47
View File
@@ -8,81 +8,95 @@ on:
env:
REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/cms
K8S_SERVER: 45.149.79.127
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: docker:latest
image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged
env:
HTTP_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
HTTPS_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
NO_PROXY: localhost,127.0.0.1,gitea-svc,45.149.79.127,10.0.0.0/8
steps:
- name: Install dependencies
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
- name: Start Docker daemon
run: |
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32082", "gitea-svc:3000"]
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
}
DAEMON
mkdir -p ~/.docker
cat > ~/.docker/config.json << 'CONF'
{
"proxies": {
"default": {
"httpProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
"httpsProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
"noProxy": "localhost,127.0.0.1,gitea-svc,45.149.79.127,10.0.0.0/8"
}
}
}
CONF
dockerd &
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break || sleep 2
echo "🚀 Starting Docker daemon..."
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
for i in $(seq 1 90); do
if docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is ready (attempt $i)"
docker version
break
else
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
sleep 2
fi
done
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
run: |
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
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-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 \
.
DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod .
- name: Push to Registry
run: |
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 }}:prod
- name: Deploy to Production
run: |
# Setup kubeconfig for PRODUCTION
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_PROD }}" | base64 -d > ~/.kube/config
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
# Restart deployment to pull new image
kubectl rollout restart deployment/cms || echo "Deployment doesn't exist yet"
# Copy K8s manifests to server
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
kubectl rollout status deployment/cms --timeout=5m || echo "Deployment rollout pending"
# Apply config (Secret) first, then deployment and update image
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)]()
[![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
Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md).
+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.kbs2.ir",
"FrontOfficeBaseUrl": "https://kbs2.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),
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 int SortOrder { get; set; }
//تعداد محصولات
public int ProductCount { get; set; }
}
@@ -53,20 +53,28 @@ public class AcceptClubMembershipContractCommandHandler
AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken)
{
// خواندن UserId از JWT (امن‌تر از دریافت از کلاینت)
if (!long.TryParse(_currentUser.UserId, out var userId))
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "کاربر احراز هویت نشده است"
};
_logger.LogInformation(
"Processing club membership contract for UserId: {UserId}",
request.UserId
userId
);
// 1. دریافت کاربر
var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found: {UserId}", request.UserId);
throw new NotFoundException(nameof(User), request.UserId);
_logger.LogWarning("User not found: {UserId}", userId);
throw new NotFoundException(nameof(User), userId);
}
// 2. بررسی خرید پکیج
@@ -127,13 +135,25 @@ public class AcceptClubMembershipContractCommandHandler
};
await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. دریافت مقادیر از SystemConstants (استاتیک)
long giftValue = SystemConstants.ClubMembershipGiftValue;
long activationFeeValue = SystemConstants.ClubActivationFee;
// 6. بارگذاری پکیج از آخرین خرید کاربر یا پکیج پایه
var latestPurchase = await _context.UserPackagePurchases
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.PurchasedAt)
.FirstOrDefaultAsync(cancellationToken);
var package = latestPurchase != null
? await _context.Packages.FirstOrDefaultAsync(p => p.Id == latestPurchase.PackageId && !p.IsDeleted, cancellationToken)
: await _context.Packages.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken);
if (package == null)
throw new NotFoundException("پکیج یافت نشد");
long giftValue = package.ActivationFee;
long activationFeeValue = package.ActivationFee;
_logger.LogInformation(
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
giftValue, activationFeeValue
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}",
package.Id, package.Title, giftValue, activationFeeValue
);
// 7. فعال‌سازی باشگاه مشتریان
@@ -148,6 +168,10 @@ public class AcceptClubMembershipContractCommandHandler
UserId = user.Id,
IsActive = true,
ActivatedAt = activationDate,
FirstActivationDate = activationDate,
FirstPackageId = package.Id,
LastActivationDate = activationDate,
LastPackageId = package.Id,
InitialContribution = activationFeeValue,
GiftValue = giftValue,
TotalEarned = 0,
@@ -167,6 +191,8 @@ public class AcceptClubMembershipContractCommandHandler
clubMembership = user.ClubMembership!;
clubMembership.IsActive = true;
clubMembership.ActivatedAt = activationDate;
clubMembership.LastActivationDate = activationDate;
clubMembership.LastPackageId = package.Id;
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(clubMembership);
@@ -198,13 +224,14 @@ public class AcceptClubMembershipContractCommandHandler
// 9. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
if (weeklyPool == null)
{
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
PackageId = package.Id,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0,
ValuePerBalance = 0,
@@ -4,10 +4,6 @@ public class AcceptClubMembershipContractCommandValidator : AbstractValidator<Ac
{
public AcceptClubMembershipContractCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر الزامی است");
RuleFor(x => x.OtpCode)
.NotEmpty()
.WithMessage("کد تایید الزامی است")
@@ -12,4 +12,9 @@ public record ActivateClubMembershipCommand : IRequest<bool>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// فعال‌سازی اجباری توسط ادمین — بدون بررسی سفارش و کیف‌پول
/// </summary>
public bool ForceActivation { get; init; }
}
@@ -54,15 +54,21 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
throw new NotFoundException(nameof(User), request.UserId);
}
// متغیر پکیج — در هر دو مسیر (عادی و Force) مقداردهی می‌شود
Package package;
// 2-5: بررسی‌های مالی — در حالت ForceActivation (ادمین) رد می‌شود
if (!request.ForceActivation)
{
// 2. بررسی اینکه پکیج خریده باشد
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
_logger.LogWarning(
"User {UserId} has not purchased golden package yet",
"User {UserId} has not purchased any package yet",
request.UserId
);
throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
"برای فعالسازی باشگاه مشتریان ابتدا باید یک پکیج خریداری کنید"
);
}
@@ -76,15 +82,18 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
if (wallet.Balance < SystemConstants.BasePackageAmount)
// NOTE: balance check uses package.Price — loaded after finding order (step 4)
// Moved to after package loading
// 3.5. بررسی وضعیت کیف‌پول جادویی — اگر در حالت Magic است، فعالسازی مجاز نیست
if (wallet.WalletMode == WalletMode.Magic)
{
_logger.LogWarning(
"User {UserId} has insufficient balance: {Balance}",
request.UserId,
wallet.Balance
"User {UserId} cannot activate club while in Magic wallet mode",
request.UserId
);
throw new BadRequestException(
$"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
"ابتدا باید چرخه کیف‌پول جادویی تکمیل شود"
);
}
@@ -104,7 +113,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
"No successful package order found for UserId: {UserId}",
request.UserId
);
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
throw new NotFoundException("سفارش پکیج یافت نشد");
}
// 5. بررسی Transaction
@@ -132,17 +141,51 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
);
}
// 5.5. بارگذاری پکیج از سفارش
package = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == packageOrder.PackageId && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException("پکیج یافت نشد");
// بررسی موجودی با مبلغ پکیج واقعی
if (wallet.Balance < package.Price)
{
throw new BadRequestException(
$"برای فعالسازی باشگاه مشتریان باید حداقل {package.Price:N0} ریال موجودی اصلی داشته باشید"
);
}
}
else
{
_logger.LogInformation(
"Force activation by admin for UserId: {UserId} — skipping order/wallet validation",
request.UserId
);
// در فعال‌سازی اجباری، اگر PackagePurchaseMethod تنظیم نشده، مقدار DirectPurchase بگذار
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
_context.Users.Update(user);
await _context.SaveChangesAsync(cancellationToken);
}
// بارگذاری پکیج پایه برای فعال‌سازی اجباری
package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException("پکیج پایه یافت نشد");
}
// 6. بررسی عضویت فعلی
var existingMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از SystemConstants
long giftValue = SystemConstants.ClubMembershipGiftValue;
long activationFeeValue = SystemConstants.ClubActivationFee;
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از پکیج
long giftValue = package.ActivationFee;
long activationFeeValue = package.ActivationFee;
_logger.LogInformation(
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
giftValue, activationFeeValue
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}, Price={Price}",
package.Id, package.Title, giftValue, activationFeeValue, package.Price
);
ClubMembership entity;
@@ -157,8 +200,12 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
UserId = user.Id,
IsActive = true,
ActivatedAt = activationDate,
InitialContribution =activationFeeValue,
GiftValue = giftValue, // مقدار از تنظیمات
FirstActivationDate = activationDate,
FirstPackageId = package.Id,
LastActivationDate = activationDate,
LastPackageId = package.Id,
InitialContribution = activationFeeValue,
GiftValue = giftValue,
TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod
};
@@ -183,10 +230,11 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
return true;
}
// فعال‌سازی مجدد
// فعال‌سازی مجدد — FirstActivation حفظ میشه (Q21), Last بروزرسانی میشه
entity = existingMembership;
entity.IsActive = true;
entity.ActivatedAt = activationDate;
entity.LastActivationDate = activationDate;
entity.LastPackageId = package.Id;
entity.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(entity);
@@ -199,6 +247,42 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
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 = package.Price,
PackageId = package.Id,
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. ثبت تاریخچه
var history = new ClubMembershipHistory
{
@@ -219,15 +303,16 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
if (weeklyPool == null)
{
// ایجاد Pool جدید برای این هفته
// ایجاد Pool جدید برای این هفته × این پکیج
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
TotalPoolAmount = activationFeeValue, // مبلغ هدیه به Pool اضافه میشه
PackageId = package.Id,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
IsCalculated = false,
@@ -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
.Where(c => c.IsActive)
.Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
.Select(c => c.UserId)
.ToHashSetAsync(cancellationToken);
@@ -76,11 +82,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.Now;
// استفاده از SystemConstants (استاتیک - بدون کوئری به دیتابیس)
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
var maxBalancesPerLeg = SystemConstants.CommissionMaxWeeklyBalancesPerLeg;
// حداکثر عمق شبکه برای شمارش اعضا (15 لول)
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
// بارگذاری پکیج پایه برای خواندن سقف‌ها
var package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
// خواندن سقف‌ها از پکیج (قبلاً از SystemConstants بود)
var maxBalancesPerLeg = package.MaxBalancesPerLeg;
var maxNetworkLevel = package.MaxNetworkLevel;
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
{
@@ -134,6 +143,7 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
{
UserId = user.Id,
WeekDefinitionId = request.WeekDefinitionId,
PackageId = package.Id,
// اطلاعات جدید
LeftLegNewMembers = leftNewMembers,
@@ -256,11 +266,12 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var count = 0;
// اگر فرزند در این هفته فعال شده، 1 امتیاز
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
// اگر فرزند در این هفته پکیج خریده (Cycle جدید ساخته شده)، 1 امتیاز
// ⚠️ از ClubMembershipCycle.PackagePurchasedAt استفاده میکنیم (نه ActivatedAt که overwrite میشد)
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;
}
@@ -49,8 +49,11 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
await _context.SaveChangesAsync(cancellationToken);
}
// ⭐ خواندن MaxNetworkLevel از SystemConstants (استاتیک)
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
// بارگذاری پکیج پایه برای خواندن سقف‌ها
var package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
var maxNetworkLevel = package.MaxNetworkLevel;
// دریافت همه تعادل‌های هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
var allWeeklyBalances = await _context.NetworkWeeklyBalances
@@ -113,6 +116,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
UserId = userId,
WeekDefinitionId = request.WeekDefinitionId,
WeeklyPoolId = pool.Id,
PackageId = package.Id,
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
ValuePerBalance = pool.ValuePerBalance,
TotalAmount = totalAmount,
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
{
/// <summary>
/// از هفته (فیلتر اختیاری)
/// از هفته — WeekDefinitionId (فیلتر اختیاری)
/// </summary>
public int? FromWeekOrder { get; init; }
public long? FromWeekDefinitionId { get; init; }
/// <summary>
/// تا هفته (فیلتر اختیاری)
/// تا هفته — WeekDefinitionId (فیلتر اختیاری)
/// </summary>
public int? ToWeekOrder { get; init; }
public long? ToWeekDefinitionId { get; init; }
/// <summary>
/// فقط Pool های محاسبه شده
@@ -20,14 +20,14 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
.AsNoTracking();
// Apply filters
if (request.FromWeekOrder!=null)
if (request.FromWeekDefinitionId != null)
{
query = query.Where(x => x.WeekDefinition.WeekOrder>=request.FromWeekOrder );
query = query.Where(x => x.WeekDefinitionId >= request.FromWeekDefinitionId);
}
if (request.ToWeekOrder!=null)
if (request.ToWeekDefinitionId != null)
{
query = query.Where(x =>x.WeekDefinition.WeekOrder<= request.ToWeekOrder);
query = query.Where(x => x.WeekDefinitionId <= request.ToWeekDefinitionId);
}
if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
@@ -18,6 +18,11 @@ public record GetMyCommissionPayoutsQuery : IRequest<GetMyCommissionPayoutsRespo
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// Pagination
/// </summary>
@@ -28,6 +28,7 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
var query = _context.UserCommissionPayouts
.Include(x => x.WeekDefinition)
.Include(x => x.Package)
.Where(x => x.UserId == userId)
.AsNoTracking()
.AsQueryable();
@@ -43,6 +44,12 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
}
// فیلتر بر اساس پکیج
if (request.PackageId.HasValue && request.PackageId.Value > 0)
{
query = query.Where(x => x.PackageId == request.PackageId.Value);
}
// مرتب‌سازی: جدیدترین اول
query = query.OrderByDescending(x => x.Created);
@@ -60,7 +67,9 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
AmountFormatted = x.TotalAmount.ToString("N0") + " تومان",
Status = x.Status,
CalculatedDate = x.PaidAt ?? (DateTime?)x.Created,
DatePersian = ""
DatePersian = "",
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : ""
})
.ToListAsync(cancellationToken);
@@ -20,4 +20,8 @@ public class GetMyCommissionPayoutsResponseModel
public CommissionPayoutStatus Status { get; set; }
public DateTime? CalculatedDate { get; set; }
public string DatePersian { get; set; } = string.Empty;
// Package info
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
}
@@ -17,6 +17,11 @@ public record GetMyWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesResponseD
/// </summary>
public bool OnlyActive { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// Pagination
/// </summary>
@@ -38,6 +38,7 @@ public class GetMyWeeklyBalancesQueryHandler : IRequestHandler<GetMyWeeklyBalanc
{
UserId = userId,
WeekDefinitionId = request.WeekDefinitionId,
PackageId = request.PackageId,
OnlyActive = request.OnlyActive,
PaginationState = request.PaginationState
};
@@ -20,6 +20,11 @@ public record GetUserCommissionPayoutsQuery : IRequest<GetUserCommissionPayoutsR
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// مرتب‌سازی
/// </summary>
@@ -4,16 +4,13 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ICurrentUserService _currentUser;
public GetUserCommissionPayoutsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository,
ICurrentUserService currentUser)
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
_currentUser = currentUser;
}
public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken)
@@ -21,20 +18,15 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
var query = _context.UserCommissionPayouts
.Include(x => x.WeekDefinition)
.Include(x => x.User)
.Include(x => x.Package)
.AsNoTracking()
.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;
if (!userId.HasValue || userId.Value == 0)
{
if (long.TryParse(_currentUser.UserId, out var currentUserId))
{
userId = currentUserId;
}
}
// فیلترها
if (userId.HasValue && userId.Value > 0)
{
query = query.Where(x => x.UserId == userId.Value);
@@ -50,6 +42,12 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
}
// فیلتر بر اساس پکیج
if (request.PackageId.HasValue && request.PackageId.Value > 0)
{
query = query.Where(x => x.PackageId == request.PackageId.Value);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -73,7 +71,9 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
WithdrawalMethod = x.WithdrawalMethod,
IbanNumber = x.IbanNumber,
WithdrawnAt = x.WithdrawnAt,
Created = x.Created
Created = x.Created,
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : ""
})
.ToListAsync(cancellationToken);
@@ -24,4 +24,8 @@ public class GetUserCommissionPayoutsResponseModel
public string? IbanNumber { get; set; }
public DateTime? WithdrawnAt { get; set; }
public DateTimeOffset Created { get; set; }
// Package info
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
}
@@ -15,6 +15,10 @@ public record GetUserWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesRespons
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// فقط موارد Expired نشده؟
@@ -4,16 +4,13 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ICurrentUserService _currentUser;
public GetUserWeeklyBalancesQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository,
ICurrentUserService currentUser)
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
_currentUser = currentUser;
}
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
@@ -21,20 +18,15 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
var query = _context.NetworkWeeklyBalances
.Include(x => x.WeekDefinition)
.Include(x => x.User)
.Include(x => x.Package)
.AsNoTracking()
.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;
if (!userId.HasValue || userId.Value == 0)
{
if (long.TryParse(_currentUser.UserId, out var currentUserId))
{
userId = currentUserId;
}
}
// فیلترها
if (userId.HasValue && userId.Value > 0)
{
query = query.Where(x => x.UserId == userId.Value);
@@ -52,6 +44,12 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
query = query.Where(x => !x.IsExpired);
}
// فیلتر بر اساس پکیج
if (request.PackageId.HasValue && request.PackageId.Value > 0)
{
query = query.Where(x => x.PackageId == request.PackageId.Value);
}
// مرتب‌سازی بر اساس WeekDefinitionId (نزولی = جدیدترین اول)
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekDefinitionId");
@@ -76,7 +74,9 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
WeeklyPoolContribution = x.WeeklyPoolContribution,
CalculatedAt = x.CalculatedAt,
IsExpired = x.IsExpired,
Created = x.Created
Created = x.Created,
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : ""
})
.ToListAsync(cancellationToken);
@@ -29,4 +29,8 @@ public class GetUserWeeklyBalancesResponseModel
public DateTime? CalculatedAt { get; set; }
public bool IsExpired { get; set; }
public DateTimeOffset Created { get; set; }
// Package info
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
}
@@ -18,7 +18,10 @@ public class LoggingBehaviour<TRequest> : IRequestPreProcessor<TRequest> where T
{
var requestName = typeof(TRequest).Name;
var userId = _currentUserService.UserId ?? string.Empty;
_logger.LogInformation("Request: {Name} {@UserId} {@Request}",
requestName, userId, request);
var safeLog = request?.ToString() ?? "";
if (safeLog.Length > 2000)
safeLog = safeLog[..2000] + "... [TRUNCATED]";
_logger.LogInformation("Request: {Name} {UserId} {Request}",
requestName, userId, safeLog);
}
}
@@ -1,16 +1,20 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.Common.Behaviours;
public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
public partial class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly Stopwatch _timer;
private readonly ILogger<TRequest> _logger;
private readonly ICurrentUserService _currentUserService;
[GeneratedRegex(@"ImageFile(?:Bytes|Mime|FileName)|ImageFile|File", RegexOptions.None)]
private static partial Regex BinaryPropertyPattern();
public PerformanceBehaviour(ILogger<TRequest> logger, ICurrentUserService currentUserService)
{
_timer = new Stopwatch();
@@ -33,11 +37,20 @@ public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequ
{
var requestName = typeof(TRequest).Name;
var userId = _currentUserService.UserId ?? string.Empty;
var safeLog = SanitizeForLog(request);
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@Request}",
requestName, elapsedMilliseconds, userId, request);
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {UserId} {Request}",
requestName, elapsedMilliseconds, userId, safeLog);
}
return response;
}
private static string SanitizeForLog(TRequest request)
{
var text = request?.ToString() ?? "";
if (text.Length > 2000)
return text[..2000] + "... [TRUNCATED]";
return text;
}
}
@@ -22,8 +22,11 @@ public class UnhandledExceptionBehaviour<TRequest, TResponse> : IPipelineBehavio
catch (Exception ex)
{
var requestName = typeof(TRequest).Name;
var safeLog = request?.ToString() ?? "";
if (safeLog.Length > 2000)
safeLog = safeLog[..2000] + "... [TRUNCATED]";
_logger.LogError(ex, "Request: Unhandled Exception for Request {Name} {@Request}", requestName, request);
_logger.LogError(ex, "Request: Unhandled Exception for Request {Name} {Request}", requestName, safeLog);
throw;
}
@@ -0,0 +1,67 @@
namespace CMSMicroservice.Application.Common.FileManager;
/// <summary>
/// سرویس مدیریت فایل — ذخیره روی دیسک، مسیر نسبی در دیتابیس
/// موقع واکشی: خواندن از دیسک و تبدیل به base64 data-URI
/// </summary>
public interface IFileManager
{
/// <summary>
/// آپلود یک فایل خام به دیسک
/// </summary>
/// <returns>نتیجه آپلود شامل مسیر نسبی فایل</returns>
/// <exception cref="FileUploadException">در صورت خطای آپلود</exception>
Task<UploadedFile> UploadAsync(
string directory,
byte[] fileBytes,
string mime,
string? fileName = null,
CancellationToken ct = default);
/// <summary>
/// آپلود تصویر با بهینه‌سازی خودکار + ساخت بندانگشتی
/// </summary>
/// <returns>نتیجه آپلود شامل تصویر اصلی و بندانگشتی (مسیرهای نسبی)</returns>
/// <exception cref="FileUploadException">در صورت خطای آپلود</exception>
Task<UploadedImage> UploadImageAsync(
string directory,
byte[] fileBytes,
string mime,
string? fileName = null,
CancellationToken ct = default);
/// <summary>
/// حذف فایل از دیسک
/// </summary>
Task DeleteAsync(long fileId, CancellationToken ct = default);
/// <summary>
/// خواندن فایل از دیسک و تبدیل به base64 data-URI
/// اگر مسیر از قبل data: باشد، همان را برمی‌گرداند
/// اگر فایل وجود نداشته باشد، رشته خالی برمی‌گرداند
/// </summary>
string ResolveImageUrl(string? path);
}
/// <summary>
/// نتیجه آپلود فایل
/// </summary>
/// <param name="Id">شناسه فایل در FMS</param>
/// <param name="Path">مسیر فایل ذخیره‌شده (مثلاً /Images/Products/abc.jpg)</param>
public sealed record UploadedFile(long Id, string Path);
/// <summary>
/// نتیجه آپلود تصویر — شامل تصویر اصلی و بندانگشتی
/// </summary>
/// <param name="Main">تصویر اصلی بهینه‌شده</param>
/// <param name="Thumbnail">تصویر بندانگشتی</param>
public sealed record UploadedImage(UploadedFile Main, UploadedFile Thumbnail);
/// <summary>
/// خطای آپلود فایل
/// </summary>
public class FileUploadException : Exception
{
public FileUploadException(string message) : base(message) { }
public FileUploadException(string message, Exception inner) : base(message, inner) { }
}
@@ -1,3 +1,5 @@
using CMSMicroservice.Domain.Entities.Blog;
using CMSMicroservice.Domain.Entities.Content;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop;
@@ -32,9 +34,12 @@ public interface IApplicationDbContext
DbSet<UserWallet> UserWallets { get; }
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
DbSet<ManualPayment> ManualPayments { get; }
DbSet<PaymentTransaction> PaymentTransactions { get; }
DbSet<PublicMessage> PublicMessages { get; }
DbSet<ClubMembership> ClubMemberships { get; }
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
DbSet<ClubMembershipCycle> ClubMembershipCycles { get; }
DbSet<PackageFeature> PackageFeatures { get; }
DbSet<ClubFeature> ClubFeatures { get; }
DbSet<UserClubFeature> UserClubFeatures { get; }
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
@@ -66,6 +71,21 @@ public interface IApplicationDbContext
DbSet<InventoryItem> InventoryItems { get; }
DbSet<StockMovement> StockMovements { get; }
// ============= Blog =============
DbSet<BlogPost> BlogPosts { get; }
DbSet<BlogCategory> BlogCategories { get; }
DbSet<BlogPostCategory> BlogPostCategories { get; }
DbSet<BlogPostTag> BlogPostTags { get; }
DbSet<BlogPostImage> BlogPostImages { get; }
// ============= Content Management =============
DbSet<SitePage> SitePages { get; }
DbSet<SitePageSection> SitePageSections { get; }
// ============= Site Page Settings (Simplified) =============
DbSet<SitePageSettings> SitePageSettingsEntities { get; }
DbSet<SitePageImage> SitePageImages { get; }
/// <summary>
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
/// </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,
CancellationToken cancellationToken = default);
/// <summary>
/// تأیید پرداخت با مبلغ — برای درگاه‌هایی مثل زرین‌پال که مبلغ را در Verify نیاز دارند
/// </summary>
/// <param name="refId">شماره مرجع تراکنش (Authority در زرین‌پال)</param>
/// <param name="verificationToken">توکن تأیید از درگاه (Status در زرین‌پال)</param>
/// <param name="amountInToman">مبلغ تراکنش به تومان</param>
/// <param name="cancellationToken"></param>
/// <returns>وضعیت نهایی تراکنش</returns>
Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
// پیش‌فرض: درگاه‌هایی که Amount نمی‌خواهند، از overload بدون amount استفاده کنند
return VerifyPaymentAsync(refId, verificationToken, cancellationToken);
}
/// <summary>
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
/// </summary>
@@ -124,6 +142,21 @@ public class PaymentVerificationResult
/// پیام
/// </summary>
public string? Message { get; set; }
/// <summary>
/// شماره کارت ماسک‌شده (مثلاً 6037-****-****-1234)
/// </summary>
public string? CardPan { get; set; }
/// <summary>
/// هش کارت بانکی
/// </summary>
public string? CardHash { get; set; }
/// <summary>
/// کد وضعیت verify از درگاه (100=موفق، 101=تکراری)
/// </summary>
public int? VerificationCode { get; set; }
}
/// <summary>
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.Common.Services;
public static class VatCalculator
{
/// <summary>
/// نرخ VAT ایران - 9 درصد
/// نرخ VAT ایران - 10 درصد
/// </summary>
public const decimal VAT_RATE = 0.09m;
public const decimal VAT_RATE = 0.10m;
/// <summary>
/// نرخ VAT به صورت درصد (9)
/// نرخ VAT به صورت درصد (10)
/// </summary>
public const int VAT_PERCENT = 9;
public const int VAT_PERCENT = 10;
/// <summary>
/// محاسبه VAT از مبلغ خالص
@@ -12,7 +12,7 @@ namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckAndProcessDayaLoa
/// 1. استعلام از API دایا
/// 2. ذخیره/به‌روزرسانی DayaLoanContract
/// 3. شارژ کیف پول برای وام‌های تأیید شده
/// 4. ثبت Order پکیج طلایی
/// 4. ثبت Order پکیج
/// 5. ارسال SMS
/// </summary>
public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndProcessDayaLoansCommand, CheckAndProcessDayaLoansResponseDto>
@@ -72,6 +72,17 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
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
var contract = await _context.DayaLoanContracts
.FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken);
@@ -175,10 +186,15 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
string contractNumber,
CancellationToken cancellationToken)
{
// بارگذاری پکیج پایه
var package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
// 1. ایجاد تراکنش
var transaction = new Transaction
{
Amount = SystemConstants.DayaLoanAmount,
Amount = package.Price,
Description = $"دریافت اعتبار دایا - قرارداد {contractNumber}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
@@ -205,13 +221,13 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
}
// 3. شارژ کیف پول عادی
wallet.Balance += SystemConstants.DayaLoanAmount;
wallet.Balance += package.Price;
var mainLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = 0,
ChangeValue = SystemConstants.DayaLoanAmount,
ChangeValue = package.Price,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
IsIncrease = true,
@@ -219,8 +235,8 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
};
await _context.UserWalletChangeLogs.AddAsync(mainLog, cancellationToken);
// 4. شارژ کیف پول تخفیف (دو برابر)
var discountAmount = SystemConstants.DayaLoanAmount * 2;
// 4. شارژ کیف پول تخفیف
var discountAmount = (long)(package.Price * package.DiscountMultiplier);
wallet.DiscountBalance += discountAmount;
var discountLog = new UserWalletChangeLog
@@ -242,28 +258,19 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
user.DayaCreditReceivedAt = DateTime.Now;
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
// 6. ثبت Order پکیج پایه
var goldenPackage = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == 4, cancellationToken);
if (goldenPackage != null)
{
// 6. ثبت UserPackagePurchase برای پکیج پایه
var goldenPackageId =goldenPackage.Id;
var packagePurchase = new UserPackagePurchase
{
UserId = user.Id,
PackageId = goldenPackageId,
PackageId = package.Id,
PurchaseMethod = PackagePurchaseMethod.DayaLoan,
PurchasedAt = DateTime.Now,
Amount = SystemConstants.DayaLoanAmount,
Amount = package.Price,
TransactionId = transaction.Id
};
await _context.UserPackagePurchases.AddAsync(packagePurchase, cancellationToken);
}
// 7. Domain Event
user.AddDomainEvent(new DayaLoanApprovedEvent(user, transaction, contractNumber));
@@ -9,4 +9,9 @@ public class AddDiscountProductImageCommand : IRequest<long>
public string ThumbnailPath { get; set; } = string.Empty;
public string? Title { 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.Domain.Entities.DiscountShop;
using MediatR;
@@ -8,10 +9,12 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.AddDiscountProduct
public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscountProductImageCommand, long>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public AddDiscountProductImageCommandHandler(IApplicationDbContext context)
public AddDiscountProductImageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<long> Handle(AddDiscountProductImageCommand request, CancellationToken cancellationToken)
@@ -23,6 +26,23 @@ public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscount
if (!productExists)
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
var maxSortOrder = await _context.DiscountProductImages
.Where(i => i.DiscountProductId == request.DiscountProductId)
@@ -31,8 +51,8 @@ public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscount
var image = new DiscountProductImage
{
DiscountProductId = request.DiscountProductId,
ImagePath = request.ImagePath,
ThumbnailPath = request.ThumbnailPath,
ImagePath = imagePath,
ThumbnailPath = thumbnailPath,
Title = request.Title,
AltText = request.AltText,
SortOrder = maxSortOrder + 1,
@@ -56,15 +56,29 @@ public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderP
// Update order
order.PaymentStatus = PaymentStatus.Success;
order.PaymentDate = DateTime.Now;
order.DeliveryStatus = DeliveryStatus.InTransit;
order.DeliveryStatus = DeliveryStatus.Pending;
// Deduct discount balance from user wallet
var userWallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
if (userWallet != null)
if (userWallet != null && order.DiscountBalanceUsed > 0)
{
userWallet.DiscountBalance -= order.DiscountBalanceUsed;
// ثبت لاگ تغییرات کیف پول
_context.UserWalletChangeLogs.Add(new Domain.Entities.UserWalletChangeLog
{
WalletId = userWallet.Id,
CurrentBalance = userWallet.Balance,
ChangeValue = 0,
CurrentNetworkBalance = userWallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = userWallet.DiscountBalance,
ChangeDiscountValue = -order.DiscountBalanceUsed,
IsIncrease = false,
RefrenceId = transaction.Id
});
}
// تایید فروش و کسر موجودی از طریق InventoryService
@@ -105,6 +119,7 @@ public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderP
transaction.PaymentStatus = PaymentStatus.Reject;
order.PaymentStatus = PaymentStatus.Reject;
order.DeliveryStatus = DeliveryStatus.Cancelled;
await _context.SaveChangesAsync(cancellationToken);
@@ -11,5 +11,15 @@ public class CreateDiscountProductCommand : IRequest<long>
public int MaxDiscountPercent { get; set; }
public string ImagePath { 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();
// 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.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Enums;
@@ -10,13 +11,16 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
{
private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService;
private readonly IFileManager _fileManager;
public CreateDiscountProductCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService)
IInventoryService inventoryService,
IFileManager fileManager)
{
_context = context;
_inventoryService = inventoryService;
_fileManager = fileManager;
}
public async Task<long> Handle(CreateDiscountProductCommand request, CancellationToken cancellationToken)
@@ -28,15 +32,42 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
FullInformation = request.FullInformation,
Price = request.Price,
MaxDiscountPercent = request.MaxDiscountPercent,
ImagePath = request.ImagePath,
ThumbnailPath = request.ThumbnailPath,
RemainingCount = 0, // موجودی اولیه صفر - باید از طریق Inventory اضافه شود
ImagePath = request.ImagePath ?? string.Empty,
ThumbnailPath = request.ThumbnailPath ?? string.Empty,
RemainingCount = 0,
Rate = 0,
SaleCount = 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);
await _context.SaveChangesAsync(cancellationToken);
@@ -18,4 +18,9 @@ public class PlaceOrderResponseDto
public long TotalAmount { get; set; }
public long DiscountBalanceUsed { 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 MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder;
@@ -12,13 +14,22 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
{
private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService;
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<PlaceOrderCommandHandler> _logger;
public PlaceOrderCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService)
IInventoryService inventoryService,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<PlaceOrderCommandHandler> logger)
{
_context = context;
_inventoryService = inventoryService;
_paymentGateway = paymentGateway;
_configuration = configuration;
_logger = logger;
}
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
@@ -98,20 +109,25 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
});
}
// Validate discount balance usage
// Always apply maximum possible discount (100%) — user cannot choose less
var maxDiscountBalanceUsable = totalDiscountAmount;
var actualDiscountBalanceUsed = Math.Min(request.DiscountBalanceToUse, maxDiscountBalanceUsable);
actualDiscountBalanceUsed = Math.Min(actualDiscountBalanceUsed, userWallet.DiscountBalance);
if (actualDiscountBalanceUsed < request.DiscountBalanceToUse)
// بررسی کافی بودن موجودی کیف پول اعتباری (تخفیفی)
if (userWallet.DiscountBalance < maxDiscountBalanceUsable)
{
return new PlaceOrderResponseDto
{
Success = false,
Message = $"موجودی تخفیف کافی نیست. حداکثر قابل استفاده: {maxDiscountBalanceUsable:N0} تومان، موجودی شما: {userWallet.DiscountBalance:N0} تومان"
Message = $"موجودی کیف پول اعتباری کافی نیست. مبلغ مورد نیاز: {maxDiscountBalanceUsable:N0} ریال، موجودی شما: {userWallet.DiscountBalance:N0} ریال"
};
}
var actualDiscountBalanceUsed = maxDiscountBalanceUsable;
_logger.LogInformation(
"Discount auto-applied: max allowed={MaxAllowed}, wallet balance={WalletBalance}, used={Used}",
maxDiscountBalanceUsable, userWallet.DiscountBalance, actualDiscountBalanceUsed);
var gatewayAmountRequired = totalAmount - actualDiscountBalanceUsed;
// Calculate VAT using centralized calculator
@@ -172,15 +188,137 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
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 && actualDiscountBalanceUsed > 0)
{
walletForDeduct.DiscountBalance -= actualDiscountBalanceUsed;
// ثبت لاگ تغییرات کیف پول
_context.UserWalletChangeLogs.Add(new Domain.Entities.UserWalletChangeLog
{
WalletId = walletForDeduct.Id,
CurrentBalance = walletForDeduct.Balance,
ChangeValue = 0,
CurrentNetworkBalance = walletForDeduct.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = walletForDeduct.DiscountBalance,
ChangeDiscountValue = -actualDiscountBalanceUsed,
IsIncrease = false,
RefrenceId = transaction.Id
});
}
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
{
Success = true,
Message = "سفارش ایجاد شد. لطفا پرداخت را تکمیل کنید",
Message = finalGatewayAmount > 0
? "سفارش ایجاد شد. در حال انتقال به درگاه پرداخت..."
: "سفارش با موفقیت ثبت و پرداخت شد",
OrderId = order.Id,
TransactionId = transaction.Id,
TotalAmount = totalAmount,
DiscountBalanceUsed = actualDiscountBalanceUsed,
GatewayAmountRequired = finalGatewayAmount
GatewayAmountRequired = finalGatewayAmount,
PaymentUrl = paymentUrl
};
}
}
@@ -12,7 +12,16 @@ public class UpdateDiscountProductCommand : IRequest<Unit>
public int MaxDiscountPercent { get; set; }
public string ImagePath { get; set; }
public string ThumbnailPath { get; set; }
public int SortOrder { get; set; }
public int RemainingCount { get; set; }
public bool IsActive { get; set; }
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.Domain.Entities.DiscountShop;
using MediatR;
@@ -8,10 +9,12 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateDiscountProd
public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscountProductCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public UpdateDiscountProductCommandHandler(IApplicationDbContext context)
public UpdateDiscountProductCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<Unit> Handle(UpdateDiscountProductCommand request, CancellationToken cancellationToken)
@@ -27,11 +30,44 @@ public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscoun
product.FullInformation = request.FullInformation;
product.Price = request.Price;
product.MaxDiscountPercent = request.MaxDiscountPercent;
product.ImagePath = request.ImagePath;
product.ThumbnailPath = request.ThumbnailPath;
product.RemainingCount = request.RemainingCount;
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
var existingCategories = await _context.DiscountProductCategories
.Where(pc => pc.ProductId == request.ProductId)
@@ -43,4 +43,6 @@ public class OrderItemDto
public int DiscountPercentUsed { get; set; }
public long DiscountAmount { 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,
DiscountPercentUsed = od.DiscountPercentUsed,
DiscountAmount = od.DiscountAmount,
FinalPrice = od.FinalPrice
FinalPrice = od.FinalPrice,
ImagePath = od.Product.ImagePath ?? "",
ThumbnailPath = od.Product.ThumbnailPath ?? ""
}).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 };
}
}

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