diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..d906d02987 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run + +**Prerequisites:** .NET 10 SDK, MongoDB 4.0+, .NET Aspire workload + +```bash +dotnet workload install aspire +dotnet restore GrandNode.sln +dotnet build GrandNode.sln --configuration Release +``` + +**Run locally:** Set `Grand.Web` as the startup project in Visual Studio 2022+, or: +```bash +dotnet run --project src/Web/Grand.Web +``` + +**Run with .NET Aspire (orchestrated, includes MongoDB):** +```bash +dotnet run --project src/Aspire/Aspire.AppHost +``` + +## Testing + +Tests require a running MongoDB instance on `localhost:27017`. + +```bash +# Run all tests +dotnet test GrandNode.sln + +# Run a single test project +dotnet test src/Tests/Grand.Business.Catalog.Tests/Grand.Business.Catalog.Tests.csproj + +# Run with coverage +dotnet test --collect:"XPlat Code Coverage" +``` + +Test projects live in `src/Tests/`. Framework: MSTest + Moq + coverlet. Some tests use Verify.MSTest for snapshot testing. + +## Architecture + +### Layer Structure + +``` +Grand.Domain → Domain models (no dependencies on other layers) +Grand.SharedKernel → Base types, extension methods +Grand.Data → MongoDB repository abstractions + implementations +Grand.Infrastructure → DI wiring, caching, plugins, type search, startup pipeline +Grand.Mapping → AutoMapper profiles +Grand.Business.* → Business logic services (depend on Domain + Data) +Grand.Web.* → ASP.NET Core web apps (depend on Business) +src/Plugins/ → Optional extensions (payment, shipping, tax, etc.) +src/Modules/ → Core modules (API, Installer, Migration, ScheduledTasks) +``` + +### Startup Pipeline + +`StartupBase.ConfigureServices` (called from `Program.cs`) orchestrates everything: +1. Registers config sections (`AppConfig`, `SecurityConfig`, `RedisConfig`, etc.) +2. Loads plugins via `PluginManager` and modules via `ModuleLoader` +3. Discovers all `IStartupApplication` implementations across all assemblies and calls them in priority order +4. Initializes AutoMapper by scanning for `IAutoMapperProfile` implementations +5. Registers MediatR handlers from all assemblies + +Adding new startup logic: implement `IStartupApplication` with `BeforeConfigure`/`Priority` properties. + +### Plugin & Module System + +- **Plugins** (`src/Plugins/`): optional extensions loaded at runtime via `PluginManager`. Each plugin has its own `.csproj` and implements plugin-specific interfaces (e.g., `IPaymentProvider`, `IShippingRateComputationMethod`). +- **Modules** (`src/Modules/`): always-loaded core extensions (API, migrations, installer, scheduled tasks). +- Plugins are only active when listed in `App_Data/InstalledPlugins.json`. +- Both plugins and modules register their services via `IStartupApplication`. + +### Data Access + +- Primary database: MongoDB via `MongoDB.Driver`. Repositories are defined in `Grand.Data` and injected via interfaces. +- `DataSettingsManager` reads the connection string from `App_Data/DataSettings.json` (or environment variable `ConnectionStrings`). +- `LiteDB` is used as a fallback/embedded option. +- `DbProvider` enum controls which provider is active. + +### CQRS / MediatR + +Business operations use MediatR. Commands and queries are in `Grand.Business.Core` interfaces; handlers live in the specific `Grand.Business.*` projects. The web layer sends commands/queries through `IMediator`. + +### Multi-Store / Multi-Tenant + +Stores, languages, and currencies are first-class entities. Most business services accept a `storeId` parameter. `IWorkContext` (in `Grand.Web.Common`) provides current request context (store, customer, language, currency). + +### Configuration Sections (appsettings.json) + +Key sections bound to strongly-typed config objects: +- `Application` → `AppConfig` +- `Performance` → `PerformanceConfig` +- `Security` → `SecurityConfig` +- `Cache` → `CacheConfig` +- `Redis` → `RedisConfig` +- `BackendAPI` / `FrontendAPI` → respective config classes +- `Amazon` / `Azure` → cloud storage configs + +## Key Directories + +| Path | Purpose | +|------|---------| +| `src/Core/` | Domain, infrastructure, data, mapping | +| `src/Business/` | Business logic services | +| `src/Web/Grand.Web/` | Main web host (entry point) | +| `src/Web/Grand.Web.Admin/` | Admin panel controllers/views | +| `src/Web/Grand.Web.Store/` | Storefront controllers/views | +| `src/Web/Grand.Web.Vendor/` | Vendor portal | +| `src/Plugins/` | Optional plugin assemblies | +| `src/Modules/` | Core module assemblies | +| `src/Tests/` | Unit test projects | +| `src/Aspire/` | .NET Aspire host + service defaults | + +## Project Guides + +Detailed per-layer guides live in `docs/guides/`. **Read the relevant guide before exploring or modifying code in that area.** Each guide covers: project responsibilities, key types, design patterns, dependencies, and how-to instructions for common tasks. + +| Guide | When to read | +|-------|-------------| +| [`docs/guides/core-layer.md`](docs/guides/core-layer.md) | Working on Grand.SharedKernel, Grand.Domain, Grand.Data, Grand.Mapping, or Grand.Infrastructure | +| [`docs/guides/business-layer.md`](docs/guides/business-layer.md) | Working on any `Grand.Business.*` project — services, CQRS handlers, events | +| [`docs/guides/web-layer.md`](docs/guides/web-layer.md) | Working on Grand.Web, Grand.Web.Admin, Grand.Web.Store, Grand.Web.Vendor, or Grand.Web.Common | +| [`docs/guides/modules.md`](docs/guides/modules.md) | Working on Grand.Module.Api, Installer, Migration, or ScheduledTasks | +| [`docs/guides/plugins.md`](docs/guides/plugins.md) | Creating or modifying payment, shipping, tax, widget, or authentication plugins | +| [`docs/guides/testing.md`](docs/guides/testing.md) | Writing tests — patterns, test helpers, infrastructure | +| [`docs/guides/aspire.md`](docs/guides/aspire.md) | Using .NET Aspire for local orchestration or adding observability | diff --git a/docs/guides/aspire.md b/docs/guides/aspire.md new file mode 100644 index 0000000000..b284ab39c2 --- /dev/null +++ b/docs/guides/aspire.md @@ -0,0 +1,72 @@ +# Aspire Guide + +Projects in `src/Aspire/`. .NET Aspire orchestration for local development and cloud-native observability. + +## Projects + +### Aspire.AppHost + +**Path:** `src/Aspire/Aspire.AppHost/` + +Orchestrates the entire application for local development. Run this instead of `Grand.Web` directly when you need a managed MongoDB instance. + +**What it provisions:** +- MongoDB container on port 27017 with `ContainerLifetime.Persistent` (survives restarts, data is preserved) +- `Grand.Web` ASP.NET Core project on port 80 + +**Usage:** +```bash +dotnet run --project src/Aspire/Aspire.AppHost +``` + +The Aspire dashboard opens automatically and shows service health, logs, and traces. + +**How MongoDB is wired:** +```csharp +var mongodb = builder.AddMongoDB("mongo") + .WithLifetime(ContainerLifetime.Persistent); + +builder.ConfigureGrandWebProject(mongodb); +// ↑ Injects the MongoDB connection string into Grand.Web's environment +``` + +`ProjectConfiguration.cs` contains the `ConfigureGrandWebProject` extension method. Modify it to add new services (Redis, messaging, etc.) to the orchestrated environment. + +**Adding a new service:** +1. Add the Aspire component package (e.g., `Aspire.Hosting.Redis`) +2. Call `builder.AddRedis("redis")` in `Program.cs` +3. Pass the resource reference to `ConfigureGrandWebProject` and inject it into `Grand.Web` + +--- + +### Aspire.ServiceDefaults + +**Path:** `src/Aspire/Aspire.ServiceDefaults/` + +Shared observability and resilience defaults. Reference this project from any service you want to instrument. + +**What it configures (single call):** +```csharp +builder.AddServiceDefaults(); +``` + +| Feature | Detail | +|---------|--------| +| OpenTelemetry Logging | Structured logs with scopes and formatting | +| OpenTelemetry Metrics | ASP.NET Core, HTTP Client, .NET Runtime metrics | +| OpenTelemetry Tracing | ASP.NET Core + HTTP Client distributed tracing | +| OTLP Exporter | Sends telemetry to Aspire dashboard (or any OTLP backend) | +| Azure Monitor | Optional integration when `APPLICATIONINSIGHTS_CONNECTION_STRING` is set | +| Service Discovery | `AddServiceDiscovery()` for named service resolution | +| HTTP Resilience | `AddStandardResilienceHandler()` on all HTTP clients | +| Health Checks | `/health` (liveness) and `/alive` (readiness) endpoints | + +**When to touch:** Adding a new telemetry source (e.g., a custom meter), changing resilience policy, or integrating a new observability backend. + +## Relationship to Non-Aspire Startup + +`Grand.Web` runs correctly both with and without Aspire: +- **With Aspire:** `Aspire.AppHost` injects `ConnectionStrings__mongo` environment variable, overriding `App_Data/DataSettings.json` +- **Without Aspire:** `DataSettingsManager` reads connection string from `App_Data/DataSettings.json` directly + +No code changes are needed in `Grand.Web` to support both modes. diff --git a/docs/guides/business-layer.md b/docs/guides/business-layer.md new file mode 100644 index 0000000000..032642a8e2 --- /dev/null +++ b/docs/guides/business-layer.md @@ -0,0 +1,263 @@ +# Business Layer Guide + +Projects in `src/Business/`. Implement domain services, CQRS handlers, and domain events. + +## Architecture Pattern + +All business logic uses **CQRS via MediatR**: + +``` +Controller / Web handler + ↓ _mediator.Send(command/query) +IRequestHandler (in Grand.Business.* or Grand.Web/Features) + ↓ uses +Business Services (interfaces defined in Grand.Business.Core) + ↓ use +IRepository (Grand.Data) + ↓ publishes +INotification events (IMediator.Publish) + ↓ handled by +Event Handlers (in Grand.Business.*) +``` + +--- + +## Grand.Business.Core + +**Path:** `src/Business/Grand.Business.Core/` + +Defines contracts for the entire business layer. No implementations live here. + +**Key sub-folders:** +- `Interfaces/` — all service contracts, organized by domain: + - `Catalog/` — IProductService, IBrandService, ICategoryService, IPricingService, ITaxService, IStockQuantityService, IDiscountService + - `Checkout/` — IOrderService, IShoppingCartService, IPaymentService, IShippingService, IGiftVoucherService, ILoyaltyPointsService + - `Authentication/` — IGrandAuthenticationService, IJwtBearerAuthenticationService, IExternalAuthenticationService, ITwoFactorAuthenticationService + - `Common/` — ILanguageService, ICurrencyService, ISettingService, ICountryService, IGroupService, IHistoryService + - `Customers/` — ICustomerService, ICustomerManagerService, IAffiliateService, IVendorService + - `Messages/` — IMessageProviderService, IEmailAccountService, IMessageTemplateService + - `Cms/` — IPageService, IBlogService, INewsService, IWidgetService + - `Storage/` — IPictureService, IDownloadService + - `Marketing/` — ICampaignService, ICourseService + +- `Commands/` — MediatR `IRequest` types that modify state (return bool or entity) +- `Queries/` — MediatR `IRequest` types that read data (return entities/lists) +- `Events/` — MediatR `INotification` domain events +- `Dto/` — lightweight DTOs for cross-layer transfer +- `Enums/` — shared enumerations (ProductSortingEnum, PaymentMethodType, ShipmentStatusEvent) +- `Utilities/` — helpers (RoundingHelper, PaymentExtensions, AffiliateExtensions) + +**When to touch:** Adding new service contracts, commands, queries, or events. Implementations go in the specific `Grand.Business.*` project. + +--- + +## Grand.Business.Catalog + +**Path:** `src/Business/Grand.Business.Catalog/` + +Products, categories, brands, collections, pricing, discounts, reviews, auctions, inventory. + +**Services:** +| Service | Key responsibilities | +|---------|---------------------| +| `ProductService` | Search with filtering/paging, tier prices, bundles, CRUD | +| `CategoryService` | Hierarchical category tree, ACL + store filtering | +| `BrandService` | Brand CRUD with ACL + store filtering | +| `PricingService` | Final price calculation: base price → discounts → taxes → customer group adjustments | +| `DiscountService` | Discount lookup and rule validation | +| `InventoryManageService` | Stock adjustments, warehouse inventory | +| `StockQuantityService` | Per-warehouse stock queries | +| `ProductReviewService` | Review CRUD, rating recalculation | +| `AuctionService` | Bid management, auction lifecycle | + +**Common patterns in this layer:** +```csharp +public class BrandService : IBrandService +{ + // Dependencies injected: IRepository, IContextAccessor, IMediator, ICacheBase + + public async Task> GetAllBrands(...) + { + // 1. Check cache + // 2. Query with store + ACL filters from IContextAccessor.WorkContext + // 3. Store in cache with typed key + // 4. Return result + } + + public async Task UpdateBrand(Brand brand) + { + await _repo.UpdateAsync(brand); + await _mediator.EntityUpdated(brand); // invalidates cache + fires handlers + } +} +``` + +**MediatR queries dispatched here:** +- `GetSearchProductsQuery` — complex product search with 20+ filter dimensions +- `UpdateProductReviewTotalsCommand` — recalculate product star ratings + +--- + +## Grand.Business.Checkout + +**Path:** `src/Business/Grand.Business.Checkout/` + +Shopping cart, order lifecycle, payment, shipping, gift vouchers, loyalty points. + +**Services:** +| Service | Key responsibilities | +|---------|---------------------| +| `OrderService` | Order CRUD, search, status transitions | +| `OrderCalculationService` | Full cart/order total: subtotal → discounts → tax → shipping → payment fee → loyalty | +| `ShoppingCartService` | Cart item add/remove/update, migration between guest and authenticated | +| `ShoppingCartValidator` | Business rule validation before add-to-cart | +| `PaymentService` | Load payment providers, route Process/Capture/Refund/Void calls | +| `ShippingService` | Calculate shipping options from IShippingRateCalculationProvider plugins | +| `GiftVoucherService` | Voucher CRUD and redemption | +| `LoyaltyPointsService` | Points earning/spending calculations | +| `MerchandiseReturnService` | Return requests management | + +**Key commands:** +- `PlaceOrderCommand` — full order creation workflow +- `CancelOrderCommand` — cancellation with inventory restore +- `RefundCommand` / `VoidCommand` — payment operations +- `ActivatedValueForPurchasedVouchersCommand` — activate gift vouchers post-payment +- `AwardLoyaltyPointsCommand` + +**Key queries:** +- `CanCancelOrderQuery` — business rule check +- `GetPersonalizedProductsQuery` + +--- + +## Grand.Business.Common + +**Path:** `src/Business/Grand.Business.Common/` + +Cross-cutting services used by all other business layers. + +**Services:** +| Service | Key responsibilities | +|---------|---------------------| +| `LanguageService` | Language CRUD, locale resolution | +| `CurrencyService` | Currency CRUD, exchange rate conversion | +| `SettingService` | Key/value settings persistence (strongly typed via `ISettings`) | +| `CountryService` | Country/state reference data | +| `GroupService` | Customer group CRUD | +| `AddressAttributeService` | Custom address fields | +| `DateTimeService` | Timezone-aware date formatting | +| `HistoryService` | Entity audit history logging | +| `SlugService` | SEO URL slug management | + +--- + +## Grand.Business.Customers + +**Path:** `src/Business/Grand.Business.Customers/` + +Customer accounts, password lifecycle, affiliates, vendors, sales employees. + +**Services:** +| Service | Key responsibilities | +|---------|---------------------| +| `CustomerService` | Search (by email, groups, registration date, tags), CRUD | +| `CustomerManagerService` | Password hashing/validation, account lockout, email confirmation tokens | +| `CustomerAttributeService` | Custom customer fields | +| `AffiliateService` | Affiliate program (tracking, commissions) | +| `VendorService` | Multi-vendor account management | +| `SalesEmployeeService` | Sales staff assignment | + +**Events published:** `CustomerRegisteredEvent`, `CustomerLoggedInEvent` + +--- + +## Grand.Business.Authentication + +**Path:** `src/Business/Grand.Business.Authentication/` + +All authentication mechanisms behind `IGrandAuthenticationService` and related interfaces. + +**Implementations:** +| Class | Mechanism | +|-------|-----------| +| `CookieAuthenticationService` | ASP.NET Core cookie auth (primary storefront login) | +| `JwtBearerAuthenticationService` | JWT tokens for API access | +| `JwtBearerCustomerAuthenticationService` | Customer-scoped JWT | +| `ApiAuthenticationService` | API key authentication | +| `ExternalAuthenticationService` | Delegates to `IExternalAuthenticationProvider` plugins (Google, Facebook) | +| `TwoFactorAuthenticationService` | TOTP-based 2FA | +| `RefreshTokenService` | JWT refresh token management | + +--- + +## Grand.Business.Cms + +**Path:** `src/Business/Grand.Business.Cms/` + +Content pages, blog, news, widgets, robots.txt, cookie consent. + +**Services:** +| Service | Key responsibilities | +|---------|---------------------| +| `PageService` | Static CMS pages with SEO slugs | +| `BlogService` | Blog posts, categories, comments | +| `NewsService` | News articles | +| `WidgetService` | Widget/plugin zone management | +| `RobotsTxtService` | Generates SEO robots.txt | +| `CookiePreference` | GDPR cookie consent tracking | + +--- + +## Grand.Business.Marketing + +**Path:** `src/Business/Grand.Business.Marketing/` + +Email campaigns, courses, contact forms, geographic data, contact attributes. + +**Services:** +| Service | Key responsibilities | +|---------|---------------------| +| `CampaignService` | Bulk email campaign management | +| `CourseService` | Training course and lesson management | +| `ContactUsService` | Contact form submissions | +| `CustomerCoordinatesService` | Geographic tracking | +| `ContactAttributeService` | Custom contact form fields | + +--- + +## Grand.Business.Messages + +**Path:** `src/Business/Grand.Business.Messages/` + +Email/notification system with templates and async queuing. + +**Services:** +| Service | Key responsibilities | +|---------|---------------------| +| `MessageProviderService` | Central dispatcher — selects template, populates tokens, queues message | +| `MessageTemplateService` | Template CRUD | +| `MessageTokenProvider` | Token substitution (`{{Order.Total}}`, `{{Customer.Email}}`, etc.) | +| `QueuedEmailService` | Email queue CRUD, retry management | +| `EmailSender` | SMTP delivery | +| `EmailAccountService` | SMTP account configuration | + +**Event integration:** Handles `OrderPlacedEvent`, `CustomerRegisteredEvent`, etc. to trigger notification emails. Listens via `INotificationHandler`. + +--- + +## Grand.Business.Storage + +**Path:** `src/Business/Grand.Business.Storage/` + +Media files (images) and digital downloads with pluggable storage backends. + +**Services:** +| Service | Key responsibilities | +|---------|---------------------| +| `PictureService` | Image CRUD, resizing, format conversion, URL generation | +| `AmazonPictureService` | AWS S3 backend | +| `AzurePictureService` | Azure Blob Storage backend | +| `FileSystemStore` | Local filesystem backend | +| `DownloadService` | Digital product download management | + +**Backend selection** is configuration-driven. All backends implement the same interface; swap via `appsettings.json`. diff --git a/docs/guides/core-layer.md b/docs/guides/core-layer.md new file mode 100644 index 0000000000..1cebcc843a --- /dev/null +++ b/docs/guides/core-layer.md @@ -0,0 +1,232 @@ +# Core Layer Guide + +Projects in `src/Core/`. These form the foundational layers with no dependencies on web or business logic. + +## Dependency Order + +``` +Grand.SharedKernel (no deps) + ↓ +Grand.Domain (→ SharedKernel) + ↓ +Grand.Data (→ Domain + SharedKernel) +Grand.Mapping (self-contained) + ↓ +Grand.Infrastructure (→ all above) +``` + +--- + +## Grand.SharedKernel + +**Path:** `src/Core/Grand.SharedKernel/` + +Foundational types and utilities used across the entire solution. Zero dependencies on other projects. + +**Key types:** +- `GrandException` — application-level exception base +- `IStartupBase` — interface for one-time startup tasks with priority ordering +- `FieldSizeLimits` — DB field size constants (NameMaxLength=200, EmailMaxLength=150) +- `CommonHelper` — string validation, type conversion, random generation (uses `RandomNumberGenerator.Create()` for crypto safety) +- `ExtendedLinq` — async LINQ extensions: `AllAsync`, `AnyAsync`, `ContainsAny`, `CartesianProduct` + +**Attributes:** +- `ApiControllerAttribute` / `ApiGroupAttribute` — API grouping metadata +- `DBFieldNameAttribute` — maps properties to MongoDB field names +- `IgnoreApiAttribute` — excludes type from API generation +- `InterfaceConverterAttribute` — JSON converter hint for interface deserialization + +**When to touch:** Adding a truly cross-cutting utility, constant, or attribute that belongs to no specific layer. + +--- + +## Grand.Domain + +**Path:** `src/Core/Grand.Domain/` + +Pure domain model. No infrastructure dependencies. Contains entities, enums, and domain interfaces. + +**Entity hierarchy:** +``` +ParentEntity → string Id + └─ BaseEntity → + audit fields (CreatedOnUtc, CreatedBy, UpdatedOnUtc, UpdatedBy, UserFields) + └─ all concrete entities +``` + +`BaseEntity` implements `IAuditableEntity`. Audit fields are auto-populated by repositories. + +**Key domain interfaces (mix-in style):** +- `ITranslationEntity` — entity supports localized strings +- `ISlugEntity` — entity has SEO-friendly URL slug +- `IStoreLinkEntity` — entity is scoped to specific stores +- `IGroupLinkEntity` — entity is scoped to customer groups (ACL) + +**Domain folders by area:** +| Folder | Notable entities | +|--------|-----------------| +| `Catalog/` | Product, Category, Brand, Collection, ProductAttribute, TierPrice, Bid | +| `Customers/` | Customer (owns Address list, ShoppingCartItem list, CustomAttribute list) | +| `Orders/` | Order, OrderItem, ShipmentItem, MerchandiseReturn | +| `Discounts/` | Discount, DiscountUsageHistory | +| `Configuration/` | Setting (name/value pairs), GrandNodeVersion | +| `Localization/` | Language, LocalizedProperty | +| `Common/` | Address, UserField, CustomAttribute | +| `Stores/` | Store, DomainHost | +| `Vendors/` | Vendor, VendorNote | +| `Cms/` | Page, BlogPost, BlogCategory, News | + +**Design patterns:** +- Aggregate root — Product owns nested collections (attributes, tier prices, reviews) +- Value objects — Address, GeoCoordinates, Reference +- Soft delete — `Deleted` flag on many entities (not physical removal) +- Enum-based type systems — ProductType, PasswordFormat, DownloadActivationType +- Localization is deferred — stored as `LocalizedProperty` entities, not embedded fields + +**When to touch:** Adding/modifying domain entities or domain-specific interfaces. Never add infrastructure concerns here. + +--- + +## Grand.Data + +**Path:** `src/Core/Grand.Data/` + +Data persistence abstraction. Supports MongoDB (primary) and LiteDB (embedded fallback). + +**Core interfaces:** +- `IRepository` — generic CRUD + advanced operations +- `IDatabaseContext` — database-level ops (CreateTable, DeleteTable, CreateIndex) +- `IStoreFilesContext` — binary file storage (GridFS for Mongo, embedded for LiteDB) +- `IAuditInfoProvider` — supplies current user + timestamp for audit field population + +**`IRepository` key methods:** +```csharp +// Basic CRUD +Task GetByIdAsync(string id) +Task InsertAsync(T entity) +Task UpdateAsync(T entity) +Task DeleteAsync(T entity) + +// Querying +IQueryable Table // LINQ entry point +Task GetOneAsync(Expression> filter) + +// Bulk +Task UpdateManyAsync(Expression> filter, UpdateBuilder update) +Task DeleteManyAsync(Expression> filter) + +// Sub-document operations (nested arrays) +Task AddToSet(string id, Expression>> field, U value) +Task UpdateToSet(string id, Expression>> field, Expression> selector, U value) +Task PullFilter(string id, Expression>> field, Expression> filter) + +// Atomic operations +Task UpdateField(string id, Expression> field, U value) +Task IncField(string id, Expression> field, U value) +``` + +**Configuration:** +- `DataSettings` — ConnectionString + `DbProvider` enum (MongoDB, CosmosDB, DocumentDB, LiteDB) +- `DataSettingsManager` — singleton, lazy-loaded, reads `App_Data/DataSettings.json` or `ConnectionStrings` env var + +**UpdateBuilder** — fluent API for partial updates, works across both backends: +```csharp +var update = new UpdateBuilder() + .Set(x => x.Name, "New Name") + .Inc(x => x.StockQuantity, -1); +await _repo.UpdateManyAsync(x => x.CategoryIds.Contains(catId), update); +``` + +**When to touch:** Adding new repository operations or switching/adding database backends. + +--- + +## Grand.Mapping + +**Path:** `src/Core/Grand.Mapping/` + +Custom lightweight object mapper. Zero-reflection at runtime — all mapping is compiled to IL delegates during startup. + +**Key types:** +- `MapperConfiguration` — builder that compiles mapping profiles +- `GrandMapper` — execution engine backed by `FrozenDictionary` of compiled delegates +- `Profile` — base class for mapping profiles +- `IMapper` — simple mapping contract: `Map(source)` + +**Profile authoring:** +```csharp +public class CatalogProfile : Profile +{ + public CatalogProfile() + { + CreateMap() + .ForMember(d => d.DisplayName, o => o.MapFrom(s => s.Name)) + .ForPath(d => d.Seo.MetaTitle, o => o.MapFrom(s => s.MetaTitle)); + } +} +``` + +**Registration:** Implement `IAutoMapperProfile` (provides ordering via `Order` property). Discovered automatically by `Grand.Infrastructure` at startup. + +**When to touch:** Adding new source→destination mappings. Never add business logic here. + +--- + +## Grand.Infrastructure + +**Path:** `src/Core/Grand.Infrastructure/` + +Application wiring layer. Handles startup pipeline, caching, plugin/module loading, events, and type discovery. + +### Startup Pipeline + +`StartupBase.ConfigureServices()` orchestrates: +1. Initializes `ITypeSearcher` for reflection-based discovery +2. Initializes database via `DataSettingsManager` +3. Loads modules (`ModuleLoader`) and plugins (`PluginManager`) +4. Compiles Roslyn CTX scripts +5. Discovers and executes `IStartupApplication` implementations (BeforeConfigure phase) +6. Initializes AutoMapper from `IAutoMapperProfile` implementations +7. Registers `ITypeConverter` implementations +8. Registers FluentValidation validators +9. Registers MediatR handlers from all assemblies +10. Executes `IStartupApplication` (AfterConfigure phase) +11. Runs `IStartupBase` one-time tasks + +`StartupBase.ConfigureRequestPipeline()` — calls `Configure()` on each `IStartupApplication` in priority order. + +**To add startup logic:** Implement `IStartupApplication` with appropriate `Priority` and register via DI. + +### Caching + +- `ICacheBase` — abstraction: `GetAsync`, `SetAsync`, `RemoveAsync`, `RemoveByPrefix`, `Clear` +- `MemoryCacheBase` — in-memory with semaphore-based thundering-herd prevention +- Cache keys defined as constants in `Infrastructure/Caching/Constants/` +- Redis support via `RedisMessageCacheManager` and `RedisMessageBus` +- Cache invalidation propagated via `IMessageBus` for distributed scenarios + +### Plugin System + +- `IPlugin` + `BasePlugin` — Install/Uninstall lifecycle +- `PluginManager` — discovers plugins from `Plugins/` folder by reflection, validates `SupportedPluginVersion`, loads via custom `AssemblyLoadContext`, registers as MVC `ApplicationParts` +- Active plugins tracked in `App_Data/InstalledPlugins.json` +- `PluginInfo` assembly attribute provides friendly name, group, system name, author, version + +### Module System + +- `ModuleLoader` — always-loaded core extensions, controlled by `FeatureManagement` config section +- Same `AssemblyLoadContext` isolation as plugins + +### Event System + +- Domain events extend MediatR `INotification` +- Generic entity events: `EntityInserted`, `EntityUpdated`, `EntityDeleted` +- Cache events: `CacheEvent`, `EntityCacheEvent` +- Published via `IMediator.Publish()` + +### Type Searching + +- `ITypeSearcher` — scans loaded assemblies for types implementing given interfaces +- Powers plugin/module discovery, startup registration, mapper profile discovery + +**Configuration classes** (from `appsettings.json`): +- `AppConfig`, `PerformanceConfig`, `SecurityConfig`, `CacheConfig`, `RedisConfig`, `ExtensionsConfig` diff --git a/docs/guides/modules.md b/docs/guides/modules.md new file mode 100644 index 0000000000..c1e616bd12 --- /dev/null +++ b/docs/guides/modules.md @@ -0,0 +1,145 @@ +# Modules Guide + +Projects in `src/Modules/`. Modules are always-loaded core extensions (unlike plugins which are optional). Controlled by `FeatureManagement` configuration section. + +All modules implement `IStartupApplication` and are loaded by `ModuleLoader` at startup via a custom `AssemblyLoadContext`. + +--- + +## Grand.Module.Api + +**Path:** `src/Modules/Grand.Module.Api/` + +REST API for external integrations. Provides CRUD endpoints for 20+ domain types. + +**Key features:** +- OpenAPI documentation via `Scalar.AspNetCore` +- Command/Query pattern mirroring the web layer +- JSON Patch support for partial updates +- Model validation attributes + +**Structure:** +``` +Grand.Module.Api/ +├── Commands/ +│ ├── Models/ ← API request objects (CreateProductCommand, UpdateOrderCommand) +│ └── Handlers/ ← IRequestHandler implementations +├── Queries/ +│ ├── Models/ +│ └── Handlers/ +├── DTOs/ ← API response shapes (ProductDto, OrderDto, CategoryDto) +├── Controllers/ ← API controllers organized by domain +└── Infrastructure/ ← Swagger/Scalar setup, auth schemes, versioning +``` + +**Authentication:** Supports JWT Bearer and API key authentication (configured via `BackendAPI` / `FrontendAPI` settings). + +**How to add a new API endpoint:** +1. Create DTO in `DTOs/YourDomain/` +2. Create command/query model in `Commands/Models/` or `Queries/Models/` +3. Create handler implementing `IRequestHandler` +4. Register handler in `ServiceCollectionExtensions.RegisterRequestHandler()` +5. Create API controller method that dispatches via `IMediator` + +--- + +## Grand.Module.Installer + +**Path:** `src/Modules/Grand.Module.Installer/` + +Installation wizard run on first startup when no database connection is configured. + +**Key features:** +- Database provider selection (MongoDB / LiteDB) +- Multi-step install UI with localization +- Seed data for all domain types + +**Seed data classes** (`Services/InstallData*.cs`): +Each class seeds a specific domain — brands, categories, products, customers, permissions, settings, email templates, etc. They run sequentially during installation. + +**Extension point:** Add new `InstallDataXxx.cs` class to seed additional data for new domains. + +**Priority:** `IStartupApplication.Priority = 100` (runs after core infrastructure). + +--- + +## Grand.Module.Migration + +**Path:** `src/Modules/Grand.Module.Migration/` + +Database schema and data migrations for version upgrades. + +**Version history covered:** 1.1, 2.0, 2.1, 2.2, 2.3, 2.4 + +**Structure:** +``` +Migrations/ +├── Migration_1.1/ +│ └── MigrationUpgradeDbVersion_1.1.cs +├── Migration_2.0/ +│ └── MigrationUpgradeDbVersion_2.0.cs +└── ... +``` + +**Pattern:** +```csharp +public class MigrationUpgradeDbVersion_2_4 : IMigration +{ + public async Task UpgradeProcess(IServiceProvider serviceProvider) + { + // Perform DB schema/data transformation + // Return MigrationResult.Success or MigrationResult.Failure + } +} +``` + +**Execution:** `MigrationProcess.RunMigrationProcess()` compares stored DB version against current version and runs pending migrations in order. Each run is tracked in the `MigrationDb` collection to prevent re-execution. + +**How to add a migration:** +1. Create new folder `Migrations/Migration_X.Y/` +2. Create class implementing `IMigration` +3. Implement `UpgradeProcess(IServiceProvider)` with the transformation logic +4. Update version check in `MigrationProcess.RunMigrationProcess()` + +--- + +## Grand.Module.ScheduledTasks + +**Path:** `src/Modules/Grand.Module.ScheduledTasks/` + +Background job execution via keyed scoped DI registration. + +**Built-in tasks:** + +| Task class | Default schedule | Purpose | +|-----------|-----------------|---------| +| `QueuedMessagesSendScheduleTask` | Every minute | Sends queued emails | +| `ClearCacheScheduleTask` | Every hour | Maintenance cache clearing | +| `GenerateSitemapXmlTask` | Daily | SEO sitemap regeneration | +| `UpdateExchangeRateScheduleTask` | Daily | Currency exchange rate sync | +| `EndAuctionsTask` | Every minute | Closes ended auctions | +| `DeleteGuestsScheduleTask` | Daily | Removes stale guest accounts | +| `CancelOrderScheduledTask` | Every minute | Cancels pending-payment-expired orders | + +**Registration pattern:** +```csharp +services.AddKeyedScoped("Send emails"); +services.AddKeyedScoped("Clear cache"); +``` + +The key string is the display name shown in the admin panel under System → Scheduled Tasks. + +**How to add a scheduled task:** +1. Create class implementing `IScheduleTask`: + ```csharp + public class MyScheduleTask : IScheduleTask + { + public MyScheduleTask(IMyService service) { ... } + public async Task Execute() { ... } + } + ``` +2. Register in your module's `StartupApplication.ConfigureServices()`: + ```csharp + services.AddKeyedScoped("My task display name"); + ``` +3. Task will appear in admin panel and can be configured with a cron expression. diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md new file mode 100644 index 0000000000..536cc6742d --- /dev/null +++ b/docs/guides/plugins.md @@ -0,0 +1,244 @@ +# Plugins Guide + +Projects in `src/Plugins/`. Plugins are optional extensions loaded at runtime. Active plugins are listed in `App_Data/InstalledPlugins.json`. + +## Plugin System Overview + +**Loading:** `PluginManager` discovers plugins by scanning the `Plugins/` output folder. Each plugin assembly is loaded in a custom `AssemblyLoadContext` and registered as an MVC `ApplicationPart`. + +**Activation:** Plugins are installed/uninstalled via Admin → Plugins. Installation calls `IPlugin.Install()` and writes the system name to `InstalledPlugins.json`. + +**Compatibility:** Each plugin declares a `SupportedPluginVersion` range. Incompatible plugins are not loaded. + +## Plugin Anatomy + +Every plugin follows this structure: + +``` +Plugins/MyPlugin/ +├── MyPlugin.cs ← IPlugin implementation (Install/Uninstall) +├── MyProviderPlugin.cs ← IXxxProvider implementation (core logic) +├── MyPluginSettings.cs ← Settings POCO +├── StartupApplication.cs ← IStartupApplication (DI registration, Priority=10) +├── EndpointProvider.cs ← IEndpointProvider (route registration) +├── Controllers/ +│ ├── Admin/ ← Configuration UI +│ └── Public/ ← Storefront integration (if needed) +├── Models/ +├── Validators/ +└── [assembly: PluginInfo(...)] ← in AssemblyInfo.cs or csproj +``` + +**Assembly attribute:** +```csharp +[assembly: PluginInfo( + FriendlyName = "Fixed Rate Shipping", + Group = "Shipping rate computation", + SystemName = "Shipping.FixedRate", + Author = "Grand.commerce", + Version = "1.0.0" +)] +``` + +**Lifecycle:** +```csharp +public class MyPlugin : BasePlugin +{ + public override async Task Install() + { + // 1. Save default settings via ISettingService + // 2. Add localization resources via IPluginTranslateResource + await base.Install(); + } + + public override async Task Uninstall() + { + // 1. Delete settings + // 2. Delete localization resources + await base.Uninstall(); + } + + public override string ConfigurationUrl => "Admin/MyPlugin/Configure"; +} +``` + +**DI registration (StartupApplication.cs):** +```csharp +public class StartupApplication : IStartupApplication +{ + public int Priority => 10; + public bool BeforeConfigure => false; + + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.AddScoped(); + } + + public void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider sp) { } +} +``` + +--- + +## Payment Plugins + +**Existing:** `Payments.BrainTree`, `Payments.CashOnDelivery`, `Payments.StripeCheckout` + +**Interface:** `IPaymentProvider : IProvider` + +**Key methods to implement:** + +| Method | Purpose | +|--------|---------| +| `InitPaymentTransaction()` | Set up transaction context | +| `ProcessPayment(ProcessPaymentRequest)` | Core payment charge | +| `PostProcessPayment(PostProcessPaymentRequest)` | Redirect / callback after payment | +| `Capture(CapturePaymentRequest)` | Capture authorized payment | +| `Refund(RefundPaymentRequest)` | Issue refund | +| `Void(VoidPaymentRequest)` | Void uncaptured authorization | +| `CancelRecurringPayment(CancelRecurringPaymentRequest)` | Cancel subscription | +| `GetAdditionalHandlingFee(IList)` | Extra fee added to order total | +| `HidePaymentMethod(IList)` | Conditional visibility in checkout | +| `SkipPaymentInfo()` | Skip payment form (e.g., for COD) | +| `ValidatePaymentForm(IFormCollection)` | Custom form validation | + +**BrainTree** also implements `IWidgetProvider` to inject its hosted fields script into the checkout page. + +--- + +## Shipping Plugins + +**Existing:** `Shipping.FixedRateShipping`, `Shipping.ByWeight`, `Shipping.ShippingPoint` + +**Interface:** `IShippingRateCalculationProvider : IProvider` + +**Key methods:** + +| Method | Purpose | +|--------|---------| +| `GetShippingOptions(GetShippingOptionRequest)` | Return list of `ShippingOption` with rates | +| `GetFixedRate(GetShippingOptionRequest)` | Return a single fixed rate (or null) | +| `HideShipmentMethods(IList)` | Conditional visibility | +| `ValidateShippingForm(IFormCollection)` | Custom form validation | +| `GetControllerRouteName()` | Route name for custom shipping form | + +`GetShippingOptionRequest` contains cart items, customer, store, shipping address, and warehouse. Rates must be returned in the store's primary currency. + +--- + +## Tax Plugins + +**Existing:** `Tax.FixedRate`, `Tax.CountryStateZip` + +**Interface:** `ITaxProvider : IProvider` + +**Key method:** +```csharp +Task GetTaxRate(TaxRateRequest taxRateRequest); +``` + +`TaxRateRequest` includes product, customer, address, tax category. Return `TaxRate` with the percentage. + +--- + +## Widget Plugins + +**Existing:** `Widgets.Slider`, `Widgets.GoogleAnalytics`, `Widgets.FacebookPixel` + +**Interface:** `IWidgetProvider : IProvider` + +**Key methods:** +```csharp +Task> GetWidgetZones(); // zones where this widget renders +string GetPublicViewComponentName(string widgetZone); // ViewComponent to render +``` + +**Built-in widget zones** (defined in `Grand.Web.Common`): header, footer, homepage hero, category page, product page, checkout, etc. + +**Widget implementation pattern:** +```csharp +public class SliderWidgetProvider : IWidgetProvider +{ + public Task> GetWidgetZones() => + Task.FromResult>(new List { + SliderWidgetDefaults.WidgetZoneHomePage, + SliderWidgetDefaults.WidgetZoneCategoryPage, + }); + + public string GetPublicViewComponentName(string widgetZone) => "WidgetSlider"; +} +``` + +Then create a `ViewComponent` class named `WidgetSliderViewComponent` that renders the actual HTML. + +--- + +## Authentication Plugins + +**Existing:** `Authentication.Google`, `Authentication.Facebook` + +**Interface:** `IExternalAuthenticationProvider : IProvider` + +**Key method:** +```csharp +string GetPublicViewComponentName(); // renders the "Login with Google" button +``` + +**OAuth wiring:** Each plugin sets up an `AuthenticationBuilder` in `StartupApplication.ConfigureServices()` using the provider-specific OAuth middleware (Google/Facebook NuGet packages). An `EventConsumer` handles post-authentication callbacks to link external accounts to local customers. + +--- + +## Discount Rule Plugins + +**Existing:** `DiscountRules.Standard` + +**Interface:** `IDiscountProvider` + +Returns a list of `IDiscountRule` implementations. Each rule: + +| Rule | Condition | +|------|-----------| +| `CustomerGroupDiscountRule` | Customer belongs to specified group | +| `HadSpentAmountDiscountRule` | Customer historically spent ≥ amount | +| `HasAllProductsDiscountRule` | All specified products are in cart | +| `HasOneProductDiscountRule` | At least one specified product is in cart | +| `ShoppingCartDiscountRule` | Cart subtotal ≥ amount | + +--- + +## Exchange Rate Plugins + +**Existing:** `ExchangeRate.McExchange` + +**Interface:** `IExchangeRateProvider : IProvider` + +```csharp +Task> GetCurrencyLiveRates(string exchangeRateCurrencyCode); +``` + +--- + +## Theme Plugins + +**Existing:** `Theme.Modern` + +Themes override Razor views and static assets without changing base project code. + +**Convention:** Theme views placed in `Themes/Modern/Views/` override the default views in `Grand.Web/Views/` by name matching. + +--- + +## How to Create a New Plugin + +1. Create folder `src/Plugins/MyPlugin/` +2. Create `.csproj` referencing `Grand.Infrastructure` (and relevant business packages) +3. Add `[assembly: PluginInfo(...)]` attribute +4. Create plugin class inheriting `BasePlugin` with `Install()`/`Uninstall()`/`ConfigurationUrl` +5. Create provider class implementing the appropriate `IXxxProvider` interface +6. Create `StartupApplication : IStartupApplication` to register the provider +7. Create `EndpointProvider : IEndpointProvider` if custom routes needed +8. Add to solution and reference from `Grand.Web` csproj (or via plugin output folder) + +**Localization keys convention:** `Plugins.[Group].[SystemName].FieldName` +e.g., `Plugins.Payments.BrainTree.Fields.UseSandbox` diff --git a/docs/guides/testing.md b/docs/guides/testing.md new file mode 100644 index 0000000000..825449c24b --- /dev/null +++ b/docs/guides/testing.md @@ -0,0 +1,185 @@ +# Testing Guide + +Projects in `src/Tests/`. One test project per production project (approximately). + +## Stack + +| Tool | Role | +|------|------| +| MSTest | Test framework (`[TestClass]`, `[TestMethod]`, `[TestInitialize]`) | +| Moq | Mocking (`Mock`, `.Setup()`, `.Verify()`) | +| `MongoDBRepositoryTest` | In-memory MongoDB substitute for repository tests | +| Coverlet | Code coverage collection (`--collect:"XPlat Code Coverage"`) | +| Verify.MSTest | Snapshot testing (some test projects) | + +**Requires:** MongoDB running on `localhost:27017` for integration-style tests that use the real repository. + +--- + +## Test Project Layout + +``` +Grand.Business.Catalog.Tests/ +├── Services/ +│ ├── BrandServiceTests.cs +│ ├── ProductServiceTests.cs +│ └── ... +├── Handlers/ +│ ├── GetSearchProductsQueryHandlerTests.cs +│ └── ... +├── Extensions/ +└── ... +``` + +Mirrors the production project structure. + +--- + +## Common Patterns + +### Service Tests (with real repository) + +```csharp +[TestClass] +public class BrandServiceTests +{ + private IBrandService _brandService; + private IRepository _brandRepository; + + [TestInitialize] + public void InitializeTests() + { + // Use in-memory MongoDB substitute + _brandRepository = new MongoDBRepositoryTest(); + + var mediatorMock = new Mock(); + var cacheBase = new MemoryCacheBase( + MemoryCacheTest.Get(), + mediatorMock.Object, + new CacheConfig { DefaultCacheTimeMinutes = 1 } + ); + + _brandService = new BrandService(_brandRepository, cacheBase, mediatorMock.Object); + } + + [TestMethod] + public async Task GetAllBrands_ReturnsOnlyPublished() + { + // Arrange + await _brandService.InsertBrand(new Brand { Published = true, Name = "A" }); + await _brandService.InsertBrand(new Brand { Published = false, Name = "B" }); + + // Act + var brands = await _brandService.GetAllBrands(showHidden: false); + + // Assert + Assert.AreEqual(1, brands.Count); + Assert.AreEqual("A", brands[0].Name); + } +} +``` + +### Handler Tests (MediatR) + +```csharp +[TestClass] +public class GetSearchProductsQueryHandlerTests +{ + [TestMethod] + public async Task Handle_ReturnsFilteredProducts() + { + // Arrange + var productRepository = new MongoDBRepositoryTest(); + // ... seed data + var handler = new GetSearchProductsQueryHandler(productRepository, ...); + var query = new GetSearchProductsQuery { Keywords = "laptop" }; + + // Act + var (products, filterableSpecs) = await handler.Handle(query, CancellationToken.None); + + // Assert + Assert.IsTrue(products.Any(p => p.Name.Contains("laptop"))); + } +} +``` + +### Domain Tests (pure logic, no DB) + +```csharp +[TestClass] +public class OrderExtensionsTests +{ + [TestMethod] + public void IsDownloadAllowed_CompletedOrder_ReturnsTrue() + { + var order = new Order { OrderStatus = OrderStatus.Complete }; + var orderItem = new OrderItem { IsDownloadActivated = true }; + + var result = order.IsDownloadAllowed(orderItem); + + Assert.IsTrue(result); + } +} +``` + +--- + +## Test Infrastructure Helpers + +**`MongoDBRepositoryTest`** — implements `IRepository` using an in-process MongoDB instance. Provides the same interface as production, so services under test behave identically. + +**`MemoryCacheTest.Get()`** — returns a test `IMemoryCache` instance with minimal configuration. + +**Mocking `IMediator`:** +```csharp +var mediatorMock = new Mock(); +mediatorMock + .Setup(m => m.Send(It.IsAny(), default)) + .ReturnsAsync(true); +``` + +**Mocking `IContextAccessor`:** +```csharp +var contextAccessor = new Mock(); +contextAccessor.Setup(x => x.WorkContext.CurrentCustomer).Returns(new Customer()); +contextAccessor.Setup(x => x.StoreContext.CurrentStore).Returns(new Store()); +``` + +--- + +## Running Tests + +```bash +# All tests (requires MongoDB on localhost:27017) +dotnet test GrandNode.sln + +# Single project +dotnet test src/Tests/Grand.Business.Catalog.Tests/Grand.Business.Catalog.Tests.csproj + +# With verbose output +dotnet test --logger "console;verbosity=detailed" + +# With coverage +dotnet test --collect:"XPlat Code Coverage" +``` + +--- + +## Adding a New Test Project + +1. Create `src/Tests/Grand.YourProject.Tests/` folder +2. Create `.csproj` referencing MSTest, Moq, and your production project +3. Follow the naming convention: `[ClassName]Tests.cs` +4. Use `[TestClass]` + `[TestInitialize]` + `[TestMethod]` +5. Add to `GrandNode.sln` under the **Tests** solution folder + +## What to Test + +| Category | Approach | +|----------|---------| +| Business service logic | Use `MongoDBRepositoryTest` + mocked `IMediator` | +| MediatR handlers | Instantiate handler directly with mocked dependencies | +| Domain extension methods | Pure unit tests, no mocks needed | +| Validators | Instantiate validator, call `.Validate(model)` | +| Import/export | Use real serialization with in-memory data | +| Controllers | Not typically unit-tested; use integration tests instead | diff --git a/docs/guides/web-layer.md b/docs/guides/web-layer.md new file mode 100644 index 0000000000..464db104e2 --- /dev/null +++ b/docs/guides/web-layer.md @@ -0,0 +1,261 @@ +# Web Layer Guide + +Projects in `src/Web/`. ASP.NET Core MVC/API with vertical-slice feature organization. + +## Request Flow + +``` +HTTP Request + ↓ +Middleware pipeline (Grand.Web.Common: ContextMiddleware, CultureSettingMiddleware, ...) + ↓ sets IWorkContext + IStoreContext +Controller (Grand.Web / Grand.Web.Admin / Grand.Web.Store / Grand.Web.Vendor) + ↓ _mediator.Send(IRequest) +Feature Handler (Grand.Web/Features/Handlers/ or Grand.Web.Admin) + ↓ uses +Business Services (Grand.Business.*) + ↓ +View / JSON response +``` + +--- + +## Grand.Web.Common + +**Path:** `src/Web/Grand.Web.Common/` + +Shared infrastructure for all web projects: context management, middleware, base controllers, security filters. + +### Context System + +**`IContextAccessor`** — thread-safe (AsyncLocal) holder for request-scoped objects: +```csharp +public interface IContextAccessor +{ + IWorkContext WorkContext { get; set; } + IStoreContext StoreContext { get; set; } +} +``` + +**`IWorkContext`** — current request's customer, language, currency, vendor, tax display type. + +**`IStoreContext`** — current store resolved from host header / cookie / route. + +**`IWorkContextSetter`** / **`IStoreContextSetter`** — initialized by `ContextMiddleware` before controllers run. They resolve the customer (authenticated or guest), language, currency, and active store. + +### Middleware (in order) + +| Middleware | Purpose | +|-----------|---------| +| `InstallUrlMiddleware` | Redirects to installer if DB not configured | +| `ContextMiddleware` | Sets `IWorkContext` + `IStoreContext` via setters | +| `CultureSettingMiddleware` | Sets `CultureInfo` from working language | +| `ContextLoggingMiddleware` | Structured request logging with store/customer context | +| `PoweredByMiddleware` | Adds `X-Powered-By` header | + +### Base Controllers + +- `BasePublicController` — injects `IContextAccessor`, applies `[DenySystemAccount]` +- `BaseAdminController` — adds `[AuthorizeAdmin]`, `[AutoValidateAntiforgeryToken]`, `[Area("Admin")]`, `[AuthorizeMenu]`; provides `GetActiveStore()` for multi-store admin scope + +### Security Filters / Attributes + +| Attribute | Effect | +|-----------|--------| +| `[DenySystemAccount]` | Blocks system/built-in accounts from accessing action | +| `[CustomerGroupAuthorize]` | Role-based access by customer group | +| `[AuthorizeAdmin]` | Requires admin role | +| `[AuthorizeMenu]` | Validates menu-level permissions | +| `[AutoValidateAntiforgeryToken]` | CSRF protection on all admin POST actions | + +--- + +## Grand.Web (Public Storefront) + +**Path:** `src/Web/Grand.Web/` + +Main customer-facing application. The entry point for `dotnet run`. + +### Controllers + +All controllers inherit `BasePublicController` and inject `IMediator`. + +| Controller | Responsibility | +|-----------|---------------| +| `AccountController` | Registration, login, profile, order history, addresses, wishlist, auctions | +| `CatalogController` | Category browsing, brand pages, product filtering | +| `CheckoutController` | Cart → address → shipping → payment → confirm → success flow | +| `ActionCartController` | AJAX cart operations (add, update, remove) | +| `OrderController` | Order details, reorder | +| `BlogController` | Blog listing, post detail | +| `ContactController` | Contact form | +| `CourseController` | Course viewing | +| `DownloadController` | Digital product downloads | +| `HomeController` | Homepage | + +**Controller pattern:** +```csharp +[DenySystemAccount] +public class CatalogController : BasePublicController +{ + private readonly IMediator _mediator; + private readonly IContextAccessor _contextAccessor; + + public async Task Category(string categoryId, CatalogPagingFilteringModel command) + { + var model = await _mediator.Send(new GetCategory { + CategoryId = categoryId, + Customer = _contextAccessor.WorkContext.CurrentCustomer, + Language = _contextAccessor.WorkContext.WorkingLanguage, + Store = _contextAccessor.StoreContext.CurrentStore, + Command = command + }); + if (model == null) return NotFound(); + return View(model); + } +} +``` + +### Features (Vertical Slices) + +`src/Web/Grand.Web/Features/` organizes complex logic as independent feature slices: + +``` +Features/ +├── Models/ ← IRequest types (the "what") +│ ├── Catalog/ +│ │ ├── GetCategory.cs +│ │ ├── GetProduct.cs +│ │ └── GetCategoryFeaturedProducts.cs +│ └── ... +└── Handlers/ ← IRequestHandler implementations (the "how") + ├── Catalog/ + │ ├── GetCategoryHandler.cs + │ └── GetCategoryFeaturedProductsHandler.cs + └── ... +``` + +Handlers are thin orchestrators: +1. Load required data from business services +2. Apply ACL / store / language filtering +3. Map entities to view models +4. Return populated view model + +### ViewComponents + +Each ViewComponent sends a MediatR request and renders a partial view: + +```csharp +public class CategoryNavigationViewComponent : ViewComponent +{ + public async Task InvokeAsync(string currentCategoryId) + { + var model = await _mediator.Send(new GetCategoryNavigation { ... }); + return View(model); + } +} +``` + +### Vue.js Frontend + +`src/Web/Grand.Web/vueapp/` contains a Vue 3 SPA for interactive UI (cart, checkout steps, product configurator). See `vueapp/README.md`. + +--- + +## Grand.Web.Admin + +**Path:** `src/Web/Grand.Web.Admin/` + +Administration panel. All controllers require admin authentication. + +### Controllers (30+) + +All inherit `BaseAdminController`. Pattern: list/create/edit/delete actions, AJAX-based grids. + +| Controller | Responsibility | +|-----------|---------------| +| `ProductController` | Product CRUD, pictures, attributes, categories, pricing rules, inventory | +| `OrderController` | Order management, shipments, returns, refunds | +| `CustomerController` | Customer accounts, groups, attribute management | +| `CategoryController` | Category tree, SEO, product assignments | +| `BrandController` / `CollectionController` | Brand/collection CRUD | +| `DiscountController` | Discount/coupon management | +| `ShippingController` | Shipping methods, warehouses, pickup points | +| `PaymentController` | Payment method configuration | +| `LanguageController` | Language management, resource import/export | +| `CurrencyController` | Currency rates | +| `StoreController` | Multi-store configuration | +| `SettingController` | All system settings sections | +| `PluginController` | Plugin install/uninstall/configure | + +**Admin action pattern:** +```csharp +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductController : BaseAdminController +{ + public async Task Edit(string id) + { + var product = await _productService.GetProductById(id); + var model = await _mediator.Send(new GetProductModel { Product = product, Store = await GetActiveStore() }); + return View(model); + } +} +``` + +--- + +## Grand.Web.Store + +**Path:** `src/Web/Grand.Web.Store/` + +Store-specific storefront controllers and components. Organized into areas, controllers, components, and models for per-store customizations separate from the main storefront. + +Contains CRUD management features exposed to store managers (as distinct from the main Admin panel), including: Pages (Topics), Email Accounts, Message Templates. + +--- + +## Grand.Web.Vendor + +**Path:** `src/Web/Grand.Web.Vendor/` + +Vendor portal for multi-vendor setups. + +**Provides:** Vendor dashboard, product management (vendor's own products), order management (vendor's orders), reports (sales, inventory). + +All controllers require vendor authentication. Vendor scope is applied automatically from `IWorkContext.CurrentVendor`. + +--- + +## Grand.Web.AdminShared + +**Path:** `src/Web/Grand.Web.AdminShared/` + +Shared models, extensions, and utilities used by both `Grand.Web.Admin` and `Grand.Web.Vendor`. Prevents duplication of view model types that appear in both admin areas. + +--- + +## Grand.SharedUIResources + +**Path:** `src/Web/Grand.SharedUIResources/` + +Static assets (CSS, JS, images, fonts) shared across admin and vendor portals. Bundled as a Razor Class Library so assets are available in any project that references it. + +--- + +## How to Add a New Admin Page + +1. Add model to `Grand.Web.Admin/Models/YourDomain/` +2. Add controller inheriting `BaseAdminController` +3. Add views in `Areas/Admin/Views/YourDomain/` +4. Register routes via `IEndpointProvider` if non-standard routing needed +5. Add menu item in the admin navigation config + +## How to Add a New Public Page + +1. Define feature request/model in `Grand.Web/Features/Models/YourDomain/` +2. Implement handler in `Grand.Web/Features/Handlers/YourDomain/` +3. Add controller action in appropriate controller (or new controller inheriting `BasePublicController`) +4. Add view in `Views/YourDomain/` diff --git a/docs/superpowers/plans/2026-05-25-store-discount-management.md b/docs/superpowers/plans/2026-05-25-store-discount-management.md new file mode 100644 index 0000000000..601c2d6351 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-store-discount-management.md @@ -0,0 +1,1605 @@ +# Store Portal — Discount Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Discount CRUD, coupon code management, and product/category assignment to the Store portal, following the same patterns as MessageTemplate/Page controllers. + +**Architecture:** The `IDiscountViewModelService` and its implementation already live in `Grand.Web.AdminShared` and are already registered via DI. No new service class or interface changes are needed — the existing methods cover all requirements. Coupon operations (list/delete) are handled directly in the controller via `IDiscountService`, matching the Admin pattern exactly. The controller sets `model.Stores = [CurrentStoreId]` before every insert/update so the mapper automatically sets `LimitedToStores = true`. + +**Tech Stack:** ASP.NET Core 10, C# 13, Kendo UI (grids + magnificPopup), Razor views with `admin-*` tag helpers, `IDiscountViewModelService` (AdminShared), `IDiscountService`, `IProductService`, `ICategoryService` + +--- + +## File Map + +| File | Status | +|------|--------| +| `src/Web/Grand.Web.Store/Controllers/DiscountController.cs` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/List.cshtml` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Create.cshtml` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Edit.cshtml` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabInfo.cshtml` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCouponCodes.cshtml` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabProducts.cshtml` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCategories.cshtml` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/ProductAddPopup.cshtml` | Create | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/CategoryAddPopup.cshtml` | Create | + +No DI registration changes. No migration. Discount already exists in sitemap (controller name "Discount" matches existing sitemap entry). + +--- + +## Task 1: Create DiscountController + +**Files:** +- Create: `src/Web/Grand.Web.Store/Controllers/DiscountController.cs` + +- [ ] **Step 1: Create the controller file** + +```csharp +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Discounts; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Discounts; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[PermissionAuthorize(PermissionSystemName.Discounts)] +public class DiscountController( + IDiscountViewModelService discountViewModelService, + IDiscountService discountService, + ITranslationService translationService, + IContextAccessor contextAccessor) : BaseStoreController +{ + private string CurrentStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public IActionResult Index() => RedirectToAction("List"); + + public IActionResult List() => View(); + + // ── List grids ──────────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task StoreDiscountList(DataSourceRequest command) + { + var allDiscounts = await discountService.GetDiscountsQuery(); + var items = allDiscounts + .Where(x => x.LimitedToStores && x.Stores.Count == 1 && x.Stores.Contains(CurrentStoreId)) + .ToList(); + + var total = items.Count; + var page = items + .Skip((command.Page - 1) * command.PageSize) + .Take(command.PageSize) + .Select(x => new { x.Id, x.Name, x.IsEnabled }) + .ToList(); + + return Json(new DataSourceResult { Data = page, Total = total }); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task GlobalDiscountList(DataSourceRequest command) + { + var allDiscounts = await discountService.GetDiscountsQuery(); + var items = allDiscounts + .Where(x => !x.LimitedToStores || x.Stores.Count > 1) + .ToList(); + + var total = items.Count; + var page = items + .Skip((command.Page - 1) * command.PageSize) + .Take(command.PageSize) + .Select(x => new { x.Id, x.Name, x.IsEnabled }) + .ToList(); + + return Json(new DataSourceResult { Data = page, Total = total }); + } + + // ── Create ─────────────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new DiscountModel(); + await discountViewModelService.PrepareDiscountModel(model, null); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(DiscountModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + model.Stores = [CurrentStoreId]; + var discount = await discountViewModelService.InsertDiscountModel(model); + Success(translationService.GetResource("Admin.Marketing.Discounts.Added")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = discount.Id }); + } + return RedirectToAction("List"); + } + + await discountViewModelService.PrepareDiscountModel(model, null); + return View(model); + } + + // ── Edit ───────────────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var discount = await discountService.GetDiscountById(id); + if (discount == null) + return RedirectToAction("List"); + + if (discount.LimitedToStores && !discount.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + ViewBag.IsReadOnly = !discount.LimitedToStores || discount.Stores.Count > 1; + + var model = discount.ToModel(); + await discountViewModelService.PrepareDiscountModel(model, discount); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(DiscountModel model, bool continueEditing) + { + var discount = await discountService.GetDiscountById(model.Id); + if (discount == null) + return RedirectToAction("List"); + + if (!discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + if (ModelState.IsValid) + { + model.Stores = [CurrentStoreId]; + await discountViewModelService.UpdateDiscountModel(discount, model); + Success(translationService.GetResource("Admin.Marketing.Discounts.Updated")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = discount.Id }); + } + return RedirectToAction("List"); + } + + await discountViewModelService.PrepareDiscountModel(model, discount); + return View(model); + } + + // ── Delete ─────────────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var discount = await discountService.GetDiscountById(id); + if (discount == null) + return RedirectToAction("List"); + + if (!discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + await discountViewModelService.DeleteDiscount(discount); + Success(translationService.GetResource("Admin.Marketing.Discounts.Deleted")); + return RedirectToAction("List"); + } + + // ── Coupon codes ───────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task CouponCodeList(DataSourceRequest command, string discountId) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var coupons = await discountService.GetAllCouponCodesByDiscountId(discount.Id, + command.Page - 1, command.PageSize); + + return Json(new DataSourceResult { + Data = coupons.Select(x => new { x.Id, x.CouponCode, x.Used }), + Total = coupons.TotalCount + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task CouponCodeInsert(string discountId, string couponCode) + { + if (string.IsNullOrEmpty(couponCode)) + throw new Exception("Coupon code can't be empty"); + + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + await discountViewModelService.InsertCouponCode(discountId, couponCode); + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task CouponCodeDelete(string discountId, string Id) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var coupon = await discountService.GetDiscountCodeById(Id); + if (coupon == null) + throw new Exception("No coupon code found with the specified id"); + + if (!coupon.Used) + { + await discountService.DeleteDiscountCoupon(coupon); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + // ── Applied to products ────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductList(DataSourceRequest command, string discountId, + [FromServices] IProductService productService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var products = await productService.GetProductsByDiscount(discount.Id, command.Page - 1, command.PageSize); + return Json(new DataSourceResult { + Data = products.Select(x => new { ProductId = x.Id, ProductName = x.Name }), + Total = products.TotalCount + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAddPopup(string discountId, + [FromServices] IProductService productService) + { + var model = await discountViewModelService.PrepareProductToDiscountModel(); + model.DiscountId = discountId; + model.SearchStoreId = CurrentStoreId; + model.AvailableStores.Clear(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopupList(DataSourceRequest command, + DiscountModel.AddProductToDiscountModel model) + { + model.SearchStoreId = CurrentStoreId; + var products = await discountViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + return Json(new DataSourceResult { Data = products.products, Total = products.totalCount }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopup(string discountId, + DiscountModel.AddProductToDiscountModel model, + [FromServices] IProductService productService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return Content("Discount not found"); + + foreach (var id in model.SelectedProductIds ?? []) + { + var product = await productService.GetProductById(id); + if (product == null || !product.Stores.Contains(CurrentStoreId)) continue; + if (product.AppliedDiscounts.All(d => d != discountId)) + await productService.InsertDiscount(discountId, product.Id); + } + + return Content(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductDelete(string discountId, string productId, + [FromServices] IProductService productService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var product = await productService.GetProductById(productId); + if (product != null) + await discountViewModelService.DeleteProduct(discount, product); + + return new JsonResult(""); + } + + // ── Applied to categories ──────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task CategoryList(DataSourceRequest command, string discountId, + [FromServices] ICategoryService categoryService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var categories = await categoryService.GetAllCategoriesByDiscount(discount.Id); + return Json(new DataSourceResult { + Data = categories.Select(x => new { CategoryId = x.Id, CategoryName = x.Name }), + Total = categories.Count + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public IActionResult CategoryAddPopup(string discountId) + { + var model = new DiscountModel.AddCategoryToDiscountModel { DiscountId = discountId }; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CategoryAddPopupList(DataSourceRequest command, + DiscountModel.AddCategoryToDiscountModel model, + [FromServices] ICategoryService categoryService) + { + var categories = await categoryService.GetAllCategories( + categoryName: model.SearchCategoryName, + pageIndex: command.Page - 1, + pageSize: command.PageSize, + showHidden: true); + + return Json(new DataSourceResult { + Data = categories.Select(x => new { x.Id, Breadcrumb = x.Name, x.Published }), + Total = categories.TotalCount + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CategoryAddPopup(string discountId, + DiscountModel.AddCategoryToDiscountModel model) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return Content("Discount not found"); + + await discountViewModelService.InsertCategoryToDiscountModel(model); + return Content(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CategoryDelete(string discountId, string categoryId, + [FromServices] ICategoryService categoryService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var category = await categoryService.GetCategoryById(categoryId); + if (category != null) + await discountViewModelService.DeleteCategory(discount, category); + + return new JsonResult(""); + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj +``` + +Expected: Build succeeded, 0 Error(s). If errors about `DiscountMappingExtensions.ToModel()` — add `using Grand.Web.AdminShared.Extensions.Mapping;`. + +- [ ] **Step 3: Commit** + +```bash +git add src/Web/Grand.Web.Store/Controllers/DiscountController.cs +git commit -m "feat: add DiscountController to Store portal" +``` + +--- + +## Task 2: Create List View + +**Files:** +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/List.cshtml` + +- [ ] **Step 1: Create the view** + +```cshtml +@inject AdminAreaSettings adminAreaSettings +@{ + ViewBag.Title = Loc["Admin.Marketing.Discounts"]; + Layout = Constants.LayoutStore; +} + +
+
+
+
+
+ + @Loc["Admin.Marketing.Discounts"] +
+ +
+
+ + + + +
+
+
+ + +
+
+
+
+
+
+
+
+
+ +``` + +- [ ] **Step 2: Verify build still passes** + +```bash +dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj +``` + +Expected: Build succeeded, 0 Error(s). + +- [ ] **Step 3: Commit** + +```bash +git add src/Web/Grand.Web.Store/Areas/Store/Views/Discount/List.cshtml +git commit -m "feat: add Discount List view to Store portal" +``` + +--- + +## Task 3: Create Create and Edit Views + +**Files:** +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Create.cshtml` +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Edit.cshtml` + +- [ ] **Step 1: Create Create.cshtml** + +```cshtml +@model DiscountModel +@{ + ViewBag.Title = Loc["Admin.Marketing.Discounts.AddNew"]; + Layout = Constants.LayoutStore; +} +
+
+
+
+
+
+ + @Loc["Admin.Marketing.Discounts.AddNew"] + + @Html.ActionLink(Loc["Admin.Marketing.Discounts.BackToList"], "List") + +
+
+
+ + +
+
+
+
+ + + + + + + +
+
+
+
+
+``` + +- [ ] **Step 2: Create Edit.cshtml** + +```cshtml +@model DiscountModel +@{ + ViewBag.Title = Loc["Admin.Marketing.Discounts.EditDiscountDetails"]; + Layout = Constants.LayoutStore; + var isReadOnly = (bool)(ViewBag.IsReadOnly ?? false); +} +
+
+
+
+
+
+ + @Loc["Admin.Marketing.Discounts.EditDiscountDetails"] - @Model.Name + + @Html.ActionLink(Loc["Admin.Marketing.Discounts.BackToList"], "List") + +
+
+
+ @if (!isReadOnly) + { + + + + @Loc["Admin.Common.Delete"] + + } +
+
+
+
+ + + + + + + + + + + + + + + + +
+
+
+
+
+@if (!isReadOnly) +{ + +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Create.cshtml src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Edit.cshtml +git commit -m "feat: add Discount Create and Edit views to Store portal" +``` + +--- + +## Task 4: Create _TabInfo Partial + +**Files:** +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabInfo.cshtml` + +- [ ] **Step 1: Create the partial** + +```cshtml +@using Grand.Domain.Discounts +@model DiscountModel + + + +
+
+
+ +
+ + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + @Loc["admin.marketing.Discounts.Fields.LimitationTimes.Times"] +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabInfo.cshtml +git commit -m "feat: add Discount _TabInfo partial to Store portal" +``` + +--- + +## Task 5: Create _TabCouponCodes Partial + +**Files:** +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCouponCodes.cshtml` + +- [ ] **Step 1: Create the partial** + +```cshtml +@model DiscountModel +@inject AdminAreaSettings adminAreaSettings + +@if (!string.IsNullOrEmpty(Model.Id)) +{ +
+
+
+
+
+ +} +else +{ +
+ @Loc["admin.marketing.Discounts.CouponCodes.SaveBeforeEdit"] +
+} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCouponCodes.cshtml +git commit -m "feat: add Discount _TabCouponCodes partial to Store portal" +``` + +--- + +## Task 6: Create _TabProducts Partial + +**Files:** +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabProducts.cshtml` + +- [ ] **Step 1: Create the partial** + +```cshtml +@model DiscountModel +@inject AdminAreaSettings adminAreaSettings + +@{ + if (!string.IsNullOrEmpty(Model.Id)) + { + + + + } + else + { +
+ @Loc["admin.marketing.Discounts.AppliedToProducts.SaveBeforeEdit"] +
+ } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabProducts.cshtml +git commit -m "feat: add Discount _TabProducts partial to Store portal" +``` + +--- + +## Task 7: Create _TabCategories Partial + +**Files:** +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCategories.cshtml` + +- [ ] **Step 1: Create the partial** + +```cshtml +@model DiscountModel +@inject AdminAreaSettings adminAreaSettings + +@{ + if (!string.IsNullOrEmpty(Model.Id)) + { + + + + } + else + { +
+ @Loc["admin.marketing.Discounts.AppliedToCategories.SaveBeforeEdit"] +
+ } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCategories.cshtml +git commit -m "feat: add Discount _TabCategories partial to Store portal" +``` + +--- + +## Task 8: Create ProductAddPopup View + +**Files:** +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/ProductAddPopup.cshtml` + +- [ ] **Step 1: Create the view** + +```cshtml +@model DiscountModel.AddProductToDiscountModel +@inject AdminAreaSettings adminAreaSettings +@{ + Layout = ""; + ViewBag.Title = Loc["admin.marketing.Discounts.AppliedToProducts.AddNew"]; +} + +
+ + + +
+
+ +
+
+ + +
+``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Web/Grand.Web.Store/Areas/Store/Views/Discount/ProductAddPopup.cshtml +git commit -m "feat: add Discount ProductAddPopup view to Store portal" +``` + +--- + +## Task 9: Create CategoryAddPopup View + +**Files:** +- Create: `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/CategoryAddPopup.cshtml` + +- [ ] **Step 1: Create the view** + +```cshtml +@model DiscountModel.AddCategoryToDiscountModel +@inject AdminAreaSettings adminAreaSettings +@{ + Layout = ""; + ViewBag.Title = Loc["admin.marketing.Discounts.AppliedToCategories.AddNew"]; +} + +
+ +
+
+
+
+
+ + @Loc["admin.marketing.Discounts.AppliedToCategories.AddNew"] +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Web/Grand.Web.Store/Areas/Store/Views/Discount/CategoryAddPopup.cshtml +git commit -m "feat: add Discount CategoryAddPopup view to Store portal" +``` + +--- + +## Task 10: Final Build Verification + +- [ ] **Step 1: Full solution build** + +```bash +dotnet build GrandNode.sln +``` + +Expected: Build succeeded, 0 Error(s). If there are compilation errors: +- `DiscountModel.ToModel()` missing — add `using Grand.Web.AdminShared.Extensions.Mapping;` to controller +- `GetDiscountsQuery()` wrong overload — check `IDiscountService` signature; pass named params as needed + +- [ ] **Step 2: Run the application and verify the Store portal** + +```bash +dotnet run --project src/Web/Grand.Web +``` + +1. Log in as a store staff user (user with `StaffStoreId` set) +2. Navigate to Store portal → Marketing → Discounts +3. Verify List page loads with two tabs (Store / Global) +4. Create a new discount → verify it saves and appears in the Store tab +5. Edit the discount → verify form fields, save works, delete works +6. Enable coupon code → verify Coupon Codes tab appears, add/delete coupons work +7. Set type to "Assigned to SKUs" → verify Products tab appears, add popup opens and filters to current store +8. Set type to "Assigned to Categories" → verify Categories tab appears, add popup opens + +- [ ] **Step 3: Final commit if any fixes were needed** + +```bash +git add -p # stage only the specific fixes +git commit -m "fix: address build/runtime issues in Store Discount feature" +``` diff --git a/docs/superpowers/specs/2026-05-25-store-discount-management-design.md b/docs/superpowers/specs/2026-05-25-store-discount-management-design.md new file mode 100644 index 0000000000..bcbb1f6ece --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-store-discount-management-design.md @@ -0,0 +1,214 @@ +# Store Portal — Discount Management + +**Date:** 2026-05-25 +**Scope:** Add Discount CRUD + coupon code management + product/category assignment to the Store portal (`Grand.Web.Store`), following the established MessageTemplate/Page controller pattern. + +--- + +## Context + +The Store portal already provides store-scoped management for Products, Categories, Collections, Pages, Email Accounts, and Message Templates. Discounts is the natural next addition: store staff manage their catalog but currently cannot create or assign store-scoped discounts. + +Recent commits (#693, #694, #680) follow an identical pattern of porting Admin features into the Store portal — this feature continues that trend. + +--- + +## Scope + +**In scope:** +- List (Store-exclusive tab + Global/read-only tab) +- Create / Edit / Delete discounts +- Coupon code management (list, add, delete) +- Product assignment tab (add popup, list, remove) +- Category assignment tab (add popup, list, remove) + +**Out of scope:** +- Discount requirement rules (plugin-based, Admin-only) +- Brand / Collection / Vendor assignment tabs (Admin-only) +- Usage history tab (Admin-only) + +--- + +## Existing Foundation + +`IDiscountViewModelService` and `DiscountViewModelService` are **already in `Grand.Web.AdminShared`**, registered in `Grand.Web.AdminShared/Startup/StartupApplication.cs`, and already consumed by `Grand.Web.Admin`. The Store portal inherits this registration automatically. + +The existing `PrepareDiscountModel(DiscountListModel, pageIndex, pageSize)` already filters by `_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId`, so store scoping in list queries is handled. + +No interface splitting or new service class is required. Two methods are missing from the existing interface and must be added: +1. Coupon code list + delete (only `InsertCouponCode` exists today) +2. `PrepareCategoryToDiscountModel` for the category assignment popup (product equivalent already exists) + +--- + +## Architecture + +``` +Grand.Web.AdminShared (already exists) + ├─ Interfaces/IDiscountViewModelService.cs ← ADD: coupon list/delete + PrepareCategoryToDiscountModel + └─ Services/DiscountViewModelService.cs ← ADD: implementations for new methods + +Grand.Web.Store (new files only) + ├─ Controllers/DiscountController.cs + └─ Areas/Store/Views/Discount/ + ├─ List.cshtml + ├─ Create.cshtml + ├─ Edit.cshtml + ├─ Partials/ + │ ├─ _CreateOrUpdate.cshtml + │ ├─ _TabCouponCodes.cshtml + │ ├─ _TabProducts.cshtml + │ └─ _TabCategories.cshtml + ├─ ProductAddPopup.cshtml + └─ CategoryAddPopup.cshtml +``` + +No new service class, no new interface, no DI registration changes. + +--- + +## Interface Changes (`IDiscountViewModelService`) + +Add to the existing interface in `Grand.Web.AdminShared/Interfaces/IDiscountViewModelService.cs`: + +```csharp +// Coupon codes — list and delete (InsertCouponCode already exists) +Task<(IEnumerable coupons, int totalCount)> + GetDiscountCouponCodes(string discountId, int pageIndex, int pageSize); +Task DeleteCouponCode(string discountId, string couponCodeId); + +// Category popup — product equivalent (PrepareProductToDiscountModel) already exists +Task PrepareCategoryToDiscountModel(); +``` + +Corresponding implementations added to `DiscountViewModelService`: +- `GetDiscountCouponCodes` — calls `IDiscountService.GetAllCouponCodesByDiscountId`, pages the result +- `DeleteCouponCode` — calls `IDiscountService.GetDiscountCodeById` → `DeleteDiscountCoupon` (only if not used) +- `PrepareCategoryToDiscountModel` — mirrors `PrepareProductToDiscountModel`, populates store list + +--- + +## Controller (`Grand.Web.Store/Controllers/DiscountController.cs`) + +Inherits `BaseStoreController`. Decorated with `[AutoValidateAntiforgeryToken]`, `[Area("Store")]`, `[AuthorizeStore]`, `[AuthorizeMenu]`. + +| Action | Method | Notes | +|--------|--------|-------| +| `List()` | GET | Renders two-tab Kendo view | +| `StoreDiscountList(command, model)` | POST | Filters: `LimitedToStores && Stores.Count == 1 && Stores.Contains(storeId)` | +| `GlobalDiscountList(command, model)` | POST | Filters: `!LimitedToStores \|\| Stores.Count > 1`; returns `IsReadOnly=true` | +| `Create()` | GET | Blank form | +| `Create(model, continueEditing)` | POST | Forces `LimitedToStores=true`, `Stores=[currentStoreId]` | +| `Edit(id)` | GET | Ownership guard; sets `ViewBag.IsReadOnly` for globals | +| `Edit(model, continueEditing)` | POST | Ownership guard before update | +| `Delete(id)` | POST | Ownership guard before delete | +| `CouponCodeList(discountId, command)` | POST | Uses new `GetDiscountCouponCodes` | +| `CouponCodeInsert(discountId, couponCode)` | POST | Uses existing `InsertCouponCode` | +| `CouponCodeDelete(discountId, id)` | POST | Uses new `DeleteCouponCode` | +| `ProductList(discountId, command)` | POST | AJAX grid | +| `ProductAddPopup(discountId)` | GET | Pre-sets `SearchStoreId = currentStoreId` (hidden) | +| `ProductAddPopupList(command, model)` | POST | Uses existing `PrepareProductModel` | +| `ProductAddPopup(discountId, model)` | POST | Uses existing `InsertProductToDiscountModel`; verifies product belongs to store | +| `ProductDelete(discountId, productId)` | POST | Uses existing `DeleteProduct` | +| `CategoryList(discountId, command)` | POST | AJAX grid | +| `CategoryAddPopup(discountId)` | GET | Uses new `PrepareCategoryToDiscountModel` | +| `CategoryAddPopupList(command, model)` | POST | Queries categories filtered by store | +| `CategoryAddPopup(discountId, model)` | POST | Uses existing `InsertCategoryToDiscountModel`; verifies category belongs to store | +| `CategoryDelete(discountId, categoryId)` | POST | Uses existing `DeleteCategory` | + +--- + +## Data Flow + +### Two-tab list + +The existing `PrepareDiscountModel(DiscountListModel, pageIndex, pageSize)` uses `GetDiscountsQuery` with `StaffStoreId`. The controller applies an additional predicate post-query to split into the two tabs: + +``` +StoreDiscountList → discounts where LimitedToStores && Stores.Count == 1 && Stores.Contains(storeId) +GlobalDiscountList → discounts where !LimitedToStores || Stores.Count > 1 (IsReadOnly = true) +``` + +### Create + +``` +POST /Store/Discount/Create(model) + → model.Stores = [currentStoreId] + → model.LimitedToStores = true + → discountViewModelService.InsertDiscountModel(model) ← calls IDiscountService.InsertDiscount internally +``` + +### Edit — ownership guard + +``` +GET /Store/Discount/Edit(id) + → IDiscountService.GetDiscountById(id) + → not found OR !discount.Stores.Contains(currentStoreId) → NotFound() + → !discount.LimitedToStores || discount.Stores.Count > 1 → ViewBag.IsReadOnly = true +``` + +### Product/category assignment — store ownership guard + +``` +POST /Store/Discount/ProductAddPopup(discountId, model) + → foreach productId in model.SelectedProductIds: + product = IProductService.GetProductById(productId) + if product == null || !product.Stores.Contains(currentStoreId) → skip + → discountViewModelService.InsertProductToDiscountModel(model) +``` + +--- + +## Error Handling & Permissions + +| Scenario | Behavior | +|----------|----------| +| Access discount not in current store | `NotFound()` | +| Edit/delete a global discount | Read-only view; POST actions return `NotFound()` | +| Delete coupon code that is already used | `DeleteCouponCode` service method returns without deleting; controller adds model error with translated message | +| Assigning product/category not in current store | Entry silently skipped | +| Missing `PermissionSystemName.Discounts` | `[AuthorizeMenu]` blocks access at controller level | + +--- + +## Sitemap + +Add to `StandardAdminSiteMap` (or equivalent registration in `Grand.Web.Store`) under the **Marketing** section: + +```csharp +new SiteMapNode { + Title = "Discounts", + ControllerName = "Discount", + ActionName = "List", + Area = Constants.AreaStore, + PermissionNames = new List { PermissionSystemName.Discounts } +} +``` + +--- + +## Files Changed / Created + +| File | Change | +|------|--------| +| `src/Web/Grand.Web.AdminShared/Interfaces/IDiscountViewModelService.cs` | **Updated** — add 3 method signatures | +| `src/Web/Grand.Web.AdminShared/Services/DiscountViewModelService.cs` | **Updated** — add 3 method implementations | +| `src/Web/Grand.Web.Store/Controllers/DiscountController.cs` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/List.cshtml` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Create.cshtml` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Edit.cshtml` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_CreateOrUpdate.cshtml` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCouponCodes.cshtml` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabProducts.cshtml` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCategories.cshtml` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/ProductAddPopup.cshtml` | **New** | +| `src/Web/Grand.Web.Store/Areas/Store/Views/Discount/CategoryAddPopup.cshtml` | **New** | +| Sitemap registration file in `Grand.Web.Store` | **Updated** — add Discount entry under Marketing | + +No new service class. No DI registration changes. No project reference changes. + +--- + +## Testing + +No new test project. The Store `DiscountController` relies on `IDiscountService` already covered by `Grand.Business.Catalog.Tests` and the existing `DiscountViewModelService` in AdminShared. Manual verification via the Store portal UI covers the controller layer, consistent with how all other Store portal features are tested. diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/CategoryAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/CategoryAddPopup.cshtml new file mode 100644 index 0000000000..43ee65691a --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/CategoryAddPopup.cshtml @@ -0,0 +1,152 @@ +@model DiscountModel.AddCategoryToDiscountModel +@inject AdminAreaSettings adminAreaSettings +@{ + Layout = ""; + ViewBag.Title = Loc["admin.marketing.Discounts.AppliedToCategories.AddNew"]; +} + +
+ +
+
+
+
+
+ + @Loc["admin.marketing.Discounts.AppliedToCategories.AddNew"] +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Create.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Create.cshtml new file mode 100644 index 0000000000..cd302d64cc --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Create.cshtml @@ -0,0 +1,41 @@ +@model DiscountModel +@{ + ViewBag.Title = Loc["Admin.Marketing.Discounts.AddNew"]; + Layout = Constants.LayoutStore; +} +
+
+
+
+
+
+ + @Loc["Admin.Marketing.Discounts.AddNew"] + + @Html.ActionLink(Loc["Admin.Marketing.Discounts.BackToList"], "List") + +
+
+
+ + +
+
+
+
+ + + + + + + +
+
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Edit.cshtml new file mode 100644 index 0000000000..aff6614ddd --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Edit.cshtml @@ -0,0 +1,61 @@ +@model DiscountModel +@{ + ViewBag.Title = Loc["Admin.Marketing.Discounts.EditDiscountDetails"]; + Layout = Constants.LayoutStore; + var isReadOnly = (bool)(ViewBag.IsReadOnly ?? false); +} +
+
+
+
+
+
+ + @Loc["Admin.Marketing.Discounts.EditDiscountDetails"] - @Model.Name + + @Html.ActionLink(Loc["Admin.Marketing.Discounts.BackToList"], "List") + +
+
+
+ @if (!isReadOnly) + { + + + + @Loc["Admin.Common.Delete"] + + } +
+
+
+
+ + + + + + + + + + + + + + + + +
+
+
+
+
+@if (!isReadOnly) +{ + +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/List.cshtml new file mode 100644 index 0000000000..63fa3def42 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/List.cshtml @@ -0,0 +1,108 @@ +@inject AdminAreaSettings adminAreaSettings +@{ + ViewBag.Title = Loc["Admin.Marketing.Discounts"]; + Layout = Constants.LayoutStore; +} + +
+
+
+
+
+ + @Loc["Admin.Marketing.Discounts"] +
+ +
+
+ + + + +
+
+
+ + +
+
+
+
+
+
+
+
+
+ diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCategories.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCategories.cshtml new file mode 100644 index 0000000000..0638b2e4d8 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCategories.cshtml @@ -0,0 +1,95 @@ +@model DiscountModel +@inject AdminAreaSettings adminAreaSettings + +@{ + if (!string.IsNullOrEmpty(Model.Id)) + { + + + + } + else + { +
+ @Loc["admin.marketing.Discounts.AppliedToCategories.SaveBeforeEdit"] +
+ } +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCouponCodes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCouponCodes.cshtml new file mode 100644 index 0000000000..678676fab0 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabCouponCodes.cshtml @@ -0,0 +1,90 @@ +@model DiscountModel +@inject AdminAreaSettings adminAreaSettings + +@if (!string.IsNullOrEmpty(Model.Id)) +{ +
+
+
+
+
+ +} +else +{ +
+ @Loc["admin.marketing.Discounts.CouponCodes.SaveBeforeEdit"] +
+} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabInfo.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabInfo.cshtml new file mode 100644 index 0000000000..0e171b1168 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabInfo.cshtml @@ -0,0 +1,182 @@ +@using Grand.Domain.Discounts +@model DiscountModel + + + +
+
+
+ +
+ + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + @Loc["admin.marketing.Discounts.Fields.LimitationTimes.Times"] +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabProducts.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabProducts.cshtml new file mode 100644 index 0000000000..3833d70d43 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/Partials/_TabProducts.cshtml @@ -0,0 +1,96 @@ +@model DiscountModel +@inject AdminAreaSettings adminAreaSettings + +@{ + if (!string.IsNullOrEmpty(Model.Id)) + { + + + + } + else + { +
+ @Loc["admin.marketing.Discounts.AppliedToProducts.SaveBeforeEdit"] +
+ } +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/ProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/ProductAddPopup.cshtml new file mode 100644 index 0000000000..8a6acc67d5 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Discount/ProductAddPopup.cshtml @@ -0,0 +1,155 @@ +@model DiscountModel.AddProductToDiscountModel +@inject AdminAreaSettings adminAreaSettings +@{ + Layout = ""; + ViewBag.Title = Loc["admin.marketing.Discounts.AppliedToProducts.AddNew"]; +} + +
+ + + +
+
+ +
+
+ + +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml index 0d49682e6d..e0cf2d08a3 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml @@ -33,6 +33,7 @@ @using Grand.Web.AdminShared.Models.Blogs @using Grand.Web.AdminShared.Models.Messages @using Grand.Web.AdminShared.Models.Pages +@using Grand.Web.AdminShared.Models.Discounts @using Grand.Web.AdminShared.Models.Settings @inject LocService Loc diff --git a/src/Web/Grand.Web.Store/Controllers/DiscountController.cs b/src/Web/Grand.Web.Store/Controllers/DiscountController.cs new file mode 100644 index 0000000000..670386ac79 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/DiscountController.cs @@ -0,0 +1,386 @@ +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Discounts; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[PermissionAuthorize(PermissionSystemName.Discounts)] +public class DiscountController( + IDiscountViewModelService discountViewModelService, + IDiscountService discountService, + ITranslationService translationService, + IContextAccessor contextAccessor, + IDateTimeService dateTimeService) : BaseStoreController +{ + private string CurrentStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public IActionResult Index() => RedirectToAction("List"); + + public IActionResult List() => View(); + + // ── List grids ──────────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task StoreDiscountList(DataSourceRequest command) + { + var allDiscounts = await discountService.GetDiscountsQuery(null, storeId: CurrentStoreId); + var items = allDiscounts + .Where(x => x.LimitedToStores && x.Stores.Count == 1 && x.Stores.Contains(CurrentStoreId)) + .ToList(); + + var total = items.Count; + var page = items + .Skip((command.Page - 1) * command.PageSize) + .Take(command.PageSize) + .Select(x => new { x.Id, x.Name, x.IsEnabled }) + .ToList(); + + return Json(new DataSourceResult { Data = page, Total = total }); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task GlobalDiscountList(DataSourceRequest command) + { + var allDiscounts = await discountService.GetDiscountsQuery(null); + var items = allDiscounts + .Where(x => !x.LimitedToStores || x.Stores.Count > 1) + .ToList(); + + var total = items.Count; + var page = items + .Skip((command.Page - 1) * command.PageSize) + .Take(command.PageSize) + .Select(x => new { x.Id, x.Name, x.IsEnabled }) + .ToList(); + + return Json(new DataSourceResult { Data = page, Total = total }); + } + + // ── Create ─────────────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new DiscountModel(); + await discountViewModelService.PrepareDiscountModel(model, null); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(DiscountModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + model.Stores = [CurrentStoreId]; + var discount = await discountViewModelService.InsertDiscountModel(model); + Success(translationService.GetResource("Admin.Marketing.Discounts.Added")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = discount.Id }); + } + return RedirectToAction("List"); + } + + await discountViewModelService.PrepareDiscountModel(model, null); + return View(model); + } + + // ── Edit ───────────────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var discount = await discountService.GetDiscountById(id); + if (discount == null) + return RedirectToAction("List"); + + if (discount.LimitedToStores && !discount.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + ViewBag.IsReadOnly = !discount.LimitedToStores || discount.Stores.Count > 1; + + var model = discount.ToModel(dateTimeService); + await discountViewModelService.PrepareDiscountModel(model, discount); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(DiscountModel model, bool continueEditing) + { + var discount = await discountService.GetDiscountById(model.Id); + if (discount == null) + return RedirectToAction("List"); + + if (!discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + if (ModelState.IsValid) + { + model.Stores = [CurrentStoreId]; + await discountViewModelService.UpdateDiscountModel(discount, model); + Success(translationService.GetResource("Admin.Marketing.Discounts.Updated")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = discount.Id }); + } + return RedirectToAction("List"); + } + + await discountViewModelService.PrepareDiscountModel(model, discount); + return View(model); + } + + // ── Delete ─────────────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var discount = await discountService.GetDiscountById(id); + if (discount == null) + return RedirectToAction("List"); + + if (!discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return RedirectToAction("List"); + + await discountViewModelService.DeleteDiscount(discount); + Success(translationService.GetResource("Admin.Marketing.Discounts.Deleted")); + return RedirectToAction("List"); + } + + // ── Coupon codes ───────────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task CouponCodeList(DataSourceRequest command, string discountId) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var coupons = await discountService.GetAllCouponCodesByDiscountId(discount.Id, + command.Page - 1, command.PageSize); + + return Json(new DataSourceResult { + Data = coupons.Select(x => new { x.Id, x.CouponCode, x.Used }), + Total = coupons.TotalCount + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CouponCodeInsert(string discountId, string couponCode) + { + if (string.IsNullOrEmpty(couponCode)) + throw new Exception("Coupon code can't be empty"); + + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + await discountViewModelService.InsertCouponCode(discountId, couponCode); + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CouponCodeDelete(string discountId, string Id) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var coupon = await discountService.GetDiscountCodeById(Id); + if (coupon == null) + throw new Exception("No coupon code found with the specified id"); + + if (coupon.DiscountId != discountId) + return NotFound(); + + if (!coupon.Used) + { + await discountService.DeleteDiscountCoupon(coupon); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + // ── Applied to products ────────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductList(DataSourceRequest command, string discountId, + [FromServices] IProductService productService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var products = await productService.GetProductsByDiscount(discount.Id, command.Page - 1, command.PageSize); + return Json(new DataSourceResult { + Data = products.Select(x => new { ProductId = x.Id, ProductName = x.Name }), + Total = products.TotalCount + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAddPopup(string discountId) + { + var model = await discountViewModelService.PrepareProductToDiscountModel(); + model.DiscountId = discountId; + model.SearchStoreId = CurrentStoreId; + model.AvailableStores.Clear(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopupList(DataSourceRequest command, + DiscountModel.AddProductToDiscountModel model) + { + model.SearchStoreId = CurrentStoreId; + var products = await discountViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + return Json(new DataSourceResult { Data = products.products, Total = products.totalCount }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopup(string discountId, + DiscountModel.AddProductToDiscountModel model, + [FromServices] IProductService productService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return Content("Discount not found"); + + foreach (var id in model.SelectedProductIds ?? []) + { + var product = await productService.GetProductById(id); + if (product == null || !product.Stores.Contains(CurrentStoreId)) continue; + if (product.AppliedDiscounts.All(d => d != discountId)) + await productService.InsertDiscount(discountId, product.Id); + } + + return Content(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductDelete(string discountId, string productId, + [FromServices] IProductService productService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var product = await productService.GetProductById(productId); + if (product != null) + await discountViewModelService.DeleteProduct(discount, product); + + return new JsonResult(""); + } + + // ── Applied to categories ──────────────────────────────────────────────── + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task CategoryList(DataSourceRequest command, string discountId, + [FromServices] ICategoryService categoryService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var categories = await categoryService.GetAllCategoriesByDiscount(discount.Id); + return Json(new DataSourceResult { + Data = categories.Select(x => new { CategoryId = x.Id, CategoryName = x.Name }), + Total = categories.Count + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public IActionResult CategoryAddPopup(string discountId) + { + var model = new DiscountModel.AddCategoryToDiscountModel { DiscountId = discountId }; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CategoryAddPopupList(DataSourceRequest command, + DiscountModel.AddCategoryToDiscountModel model, + [FromServices] ICategoryService categoryService) + { + var categories = await categoryService.GetAllCategories( + categoryName: model.SearchCategoryName, + storeId: CurrentStoreId, + pageIndex: command.Page - 1, + pageSize: command.PageSize, + showHidden: true); + + return Json(new DataSourceResult { + Data = categories.Select(x => new { x.Id, Breadcrumb = x.Name, x.Published }), + Total = categories.TotalCount + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CategoryAddPopup(string discountId, + DiscountModel.AddCategoryToDiscountModel model, + [FromServices] ICategoryService categoryService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return Content("Discount not found"); + + foreach (var id in model.SelectedCategoryIds ?? []) + { + var category = await categoryService.GetCategoryById(id); + if (category == null || !category.Stores.Contains(CurrentStoreId)) continue; + if (category.AppliedDiscounts.All(d => d != discountId)) + { + category.AppliedDiscounts.Add(discountId); + await categoryService.UpdateCategory(category); + } + } + + return Content(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CategoryDelete(string discountId, string categoryId, + [FromServices] ICategoryService categoryService) + { + var discount = await discountService.GetDiscountById(discountId); + if (discount == null || !discount.LimitedToStores || !discount.Stores.Contains(CurrentStoreId)) + return NotFound(); + + var category = await categoryService.GetCategoryById(categoryId); + if (category != null) + await discountViewModelService.DeleteCategory(discount, category); + + return new JsonResult(""); + } +}