diff --git a/.ai/checklists/README.md b/.ai/checklists/README.md new file mode 100644 index 000000000..8ff8b4085 --- /dev/null +++ b/.ai/checklists/README.md @@ -0,0 +1,19 @@ +# Checklists + +Cross-cutting gates. Skills carry their own domain checklists (a widget plugin's `Validation Checklist` belongs in the widget skill); these cover what no single skill owns. + +| Checklist | Run it | +|---|---| +| `definition-of-done.md` | Before calling any change complete | +| `code-review.md` | When reviewing someone else's diff, or your own before opening a PR | +| `security.md` | When the change touches auth, input, scoped data, secrets, or file handling | +| `performance.md` | When the change adds a query, a loop over entities, or a page render path | +| `data-change.md` | When the change touches entities, migrations, settings, or persisted identities | +| `plugin-release.md` | Before shipping a plugin or theme | + +## How to use + +1. Run `definition-of-done.md` on every change. Add the situational ones the change triggers. +2. A checklist item is a question to answer, not a box to tick by reflex. "Yes, because …" is the passing answer. +3. Report items that do not apply as N/A with a reason. Silence reads as an unchecked box. +4. An item you cannot verify is a risk to state in the PR, not an item to skip. diff --git a/.ai/checklists/code-review.md b/.ai/checklists/code-review.md new file mode 100644 index 000000000..fce937e6b --- /dev/null +++ b/.ai/checklists/code-review.md @@ -0,0 +1,107 @@ +# Checklist: Code Review + +For reviewing a diff. Report only high-confidence findings, each with file, line, the rule violated, the failure it causes, and a minimal fix. + +Use with `.ai/prompts/review-change.md`, which handles skill selection. + +--- + +## Read the diff twice + +- [ ] First pass: does the change do what its description claims? +- [ ] Second pass: what did it change that the description does not mention? + +## Layering + +- [ ] No business logic in a controller. +- [ ] No view model in a business service. +- [ ] No MongoDB driver type outside `Grand.Data`. +- [ ] No HTTP or `IWorkContext` dependency in `Grand.Domain`. +- [ ] No core → plugin reference. +- [ ] Services registered in `IStartupApplication`, not `Program.cs`. + +## Scoping — the highest-yield section + +- [ ] Every new query filters by store. +- [ ] Vendor-reachable code filters by `VendorId` and re-checks ownership on write. +- [ ] `LimitedToStores` / `LimitedToGroups` honoured where the entity supports them. +- [ ] Every new cache key contains store id — and language id if the data is localized. +- [ ] Settings loaded and saved with the same store scope. +- [ ] No `IWorkContext` in a scheduled task, migration, or plugin install. + +## Duplication and reuse + +- [ ] The logic does not already exist in a service, extension, or helper. +- [ ] A near-copy of an existing method was not introduced instead of a parameter. +- [ ] A third repetition of the same block was extracted rather than added. +- [ ] Copy-pasted code had **every** identifier updated — stale names from the source are a classic defect. + +## Validation + +- [ ] Inputs crossing a trust boundary are validated. +- [ ] Validators cover the new fields, not just the old ones. +- [ ] Guard clauses use `ArgumentNullException.ThrowIfNull`. +- [ ] Invalid model state does not partially save. + +## Exception handling + +- [ ] No empty `catch`, no `catch (Exception)` that continues silently. +- [ ] Expected business failures return results, not exceptions. +- [ ] Notification handlers cannot throw into the publisher. +- [ ] Migrations return `false` instead of throwing. +- [ ] Disposables are disposed. + +## Logging + +- [ ] Diagnosable failures are logged with store/entity/operation context. +- [ ] No secrets or personal data in log output. +- [ ] Log levels are proportionate. +- [ ] No logging in a hot loop. + +## Async + +- [ ] No `.Result`, `.Wait()`, `GetAwaiter().GetResult()`. +- [ ] No `async void`. +- [ ] No `Task.Run` wrapping synchronous work. +- [ ] `CancellationToken` forwarded where the surrounding signatures carry one. + +## Data lifecycle + +- [ ] Every write invalidates its cache prefix. +- [ ] Every write publishes its entity event. +- [ ] Cross-family caches that embed the entity are invalidated too. +- [ ] Migration `Identity` GUID is new. +- [ ] Migration is idempotent and destroys nothing operator-owned. +- [ ] No persisted identity renamed (plugin system name, permission name, template name, task name). + +## Localization + +- [ ] No hardcoded user-facing string. +- [ ] Resource keys follow the naming convention and are added **and** removed symmetrically in plugin install/uninstall. +- [ ] Localized entity properties read through the translation extension, not the raw property. + +## Frontend + +- [ ] Storefront data attributes preserved on touched views. +- [ ] Widget zones preserved. +- [ ] No `Html.Raw` on user content. +- [ ] Bundles rebuilt if source changed. + +## Tests + +- [ ] New behavior has tests, in the mirror project. +- [ ] Bug fixes have a regression test. +- [ ] No assertion weakened to make the suite pass. +- [ ] Tests mock at interface boundaries and hit no real database or network. + +## Backward compatibility + +- [ ] An existing installation upgrading in place still works. +- [ ] New settings default to previous behavior. +- [ ] No public interface, view model, or route changed without the PR saying so under "Breaking changes". + +## Before submitting the review + +- [ ] Every finding verified by opening the file — no speculative claims. +- [ ] Findings ranked by severity, not by file order. +- [ ] Style and preference comments omitted unless a mandatory rule is broken. diff --git a/.ai/checklists/data-change.md b/.ai/checklists/data-change.md new file mode 100644 index 000000000..0bc937f9d --- /dev/null +++ b/.ai/checklists/data-change.md @@ -0,0 +1,80 @@ +# Checklist: Data Change + +Run when the change touches a domain entity, a migration, settings, localization resources, permissions, or any persisted identity. + +The question behind every item: **what happens to a store that already has data and is running the previous version?** + +--- + +## Entity changes + +- [ ] New entity derives from `BaseEntity` (top-level) or `SubBaseEntity` (embedded). +- [ ] The marker interfaces the feature needs are implemented: `IStoreLinkEntity`, `IGroupLinkEntity`, `ISlugEntity`, `ITranslationEntity`. +- [ ] A new field has a default that makes existing documents behave as they did before. +- [ ] A removed or renamed field has a migration, or the old data is knowingly abandoned and the PR says so. +- [ ] No UI concern, persistence detail, or infrastructure dependency added to `Grand.Domain`. +- [ ] Naming matches `.ai/glossary/` — the domain word, not the nopCommerce word. + +## Migration + +- [ ] `Identity` is a freshly generated GUID, unique across the repository. +- [ ] `Version` matches the folder and the shipping release. +- [ ] `Priority` orders it correctly against the other migrations in that version. +- [ ] `UpgradeProcess` cannot throw — it catches and returns `false`. +- [ ] Running it twice is a no-op. +- [ ] It does not overwrite or delete anything an operator may have customized. +- [ ] A new version folder has its `MigrationUpgradeDbVersion_{version}` class. +- [ ] It does not read `IWorkContext` — there is no ambient context. + +## Settings + +- [ ] New settings class implements `ISettings`. +- [ ] Defaults preserve pre-upgrade behavior. +- [ ] A migration seeds the setting for existing installations. +- [ ] Store scope handled: loaded and saved with the same scope. +- [ ] System-wide fields are preserved when saving a store-scoped copy. +- [ ] `ClearCache()` called after saving. + +## Localization + +- [ ] Every new user-facing string has a resource key. +- [ ] Key naming follows `.ai/standards/naming.md`. +- [ ] Core resources ship through `App_Data/Resources/Upgrade/en_{version}.xml` plus an import migration. +- [ ] Plugin resources are added in `Install()` and **every one of them** removed in `Uninstall()`. +- [ ] Admin fields have both a label and a `.Hint` key. + +## Permissions and navigation + +- [ ] New permission registered in the `PermissionProvider`. +- [ ] Migration adds it for existing installations. +- [ ] Controllers enforce it via `[PermissionAuthorize]`. +- [ ] Admin sitemap entry added, with a migration, if the feature needs navigation. + +## Persisted identities — never rename + +- [ ] Plugin `SystemName` unchanged. +- [ ] Provider `SystemName` unchanged. +- [ ] `PermissionSystemName` unchanged. +- [ ] Message template name unchanged. +- [ ] `ScheduleTaskName` unchanged (and still equal to its DI key). +- [ ] Setting key unchanged. + +Renaming any of these orphans data in every installation. If a rename is genuinely required, it needs a migration that moves the old records, and a "Breaking changes" entry. + +## Caching and events + +- [ ] Reads of the new data are cached with a key containing store id (and language id where localized). +- [ ] Every write invalidates the prefix. +- [ ] Every write publishes the entity event. +- [ ] Other cached families that embed this entity are invalidated. + +## Indexes and query shape + +- [ ] New query patterns are supported by an index, or the omission is deliberate and stated. +- [ ] No unbounded query over a collection that grows with orders or customers. + +## Verification + +- [ ] Tested against a database that already has data, not only a fresh install. +- [ ] The upgrade path was reasoned about explicitly, and the reasoning is in the PR. +- [ ] Rollback consequences stated: what an operator does if this change is wrong. diff --git a/.ai/checklists/definition-of-done.md b/.ai/checklists/definition-of-done.md new file mode 100644 index 000000000..e977d585e --- /dev/null +++ b/.ai/checklists/definition-of-done.md @@ -0,0 +1,77 @@ +# Checklist: Definition of Done + +Run on every change before reporting it complete. + +--- + +## Scope + +- [ ] The change does what was asked — no less. +- [ ] The change does **only** what was asked; unrelated refactors and formatting churn are out. +- [ ] Anything deliberately left out is stated explicitly, with the reason. + +## Correctness + +- [ ] The happy path was exercised, not just compiled. +- [ ] Boundary cases considered: empty collection, null, zero quantity, first page, last page. +- [ ] Store scope applied to every query and every cache key the change touches. +- [ ] Vendor scope applied where a vendor could reach the code. +- [ ] Behavior verified for the product types / order statuses / payment flows the change affects, or the untested ones named. + +## Code quality + +- [ ] No duplicated logic — the closest existing helper, service, or extension was reused. If similar code exists in three places now, that is a finding. +- [ ] Follows the closest existing file's structure, naming, and idiom. +- [ ] Naming matches `.ai/standards/naming.md` and the domain vocabulary in `.ai/glossary/`. +- [ ] No constraint from `.ai/constraints.md` violated. +- [ ] Dead code, commented-out code, and debug output removed. + +## Validation + +- [ ] Every input that crosses a trust boundary is validated — FluentValidation for models, guard clauses for service arguments. +- [ ] Invalid model state re-renders rather than partially saving. +- [ ] Server-side ownership re-checked for any id that arrived in a request. + +## Exception handling + +- [ ] Expected failures return result objects, not exceptions. +- [ ] No empty `catch`. +- [ ] Nothing catches broadly and continues as if it succeeded. +- [ ] Notification handlers and migrations cannot throw into their caller. +- [ ] Resources that need disposing are in `using` blocks. + +## Logging + +- [ ] Failures that an operator would need to diagnose are logged, with enough context to identify the store, entity, and operation. +- [ ] No secrets, tokens, passwords, card data, or full personal records in log messages. +- [ ] Log levels are honest: `Error` for something broken, `Warning` for something suspicious, not everything at `Information`. +- [ ] No logging inside a hot loop. + +## Data safety + +- [ ] Every write invalidates the affected cache prefixes. +- [ ] Every write publishes its entity event. +- [ ] New settings default to the pre-change behavior. +- [ ] New user-facing strings exist as localization resources. +- [ ] New permissions have a provider entry and a migration. +- [ ] An existing installation upgrading in place still works. + +## Tests + +- [ ] Tests added for new behavior, in the mirror test project. +- [ ] For a bug fix: a test that failed before the fix and passes after. +- [ ] No existing assertion weakened or deleted to make the suite pass. +- [ ] The affected test project runs green. + +## Build and delivery + +- [ ] The affected projects build. +- [ ] Frontend bundles rebuilt and committed if frontend source changed. +- [ ] Plugin/theme output path verified for Debug **and** Release, if applicable. +- [ ] PR follows `.ai/standards/git-and-pr.md`, with a truthful "Breaking changes" section. + +## Reporting + +- [ ] Commands actually run are listed, with results. +- [ ] Commands that could not be run are named. +- [ ] Remaining risk is stated plainly — not omitted because it is small. diff --git a/.ai/checklists/performance.md b/.ai/checklists/performance.md new file mode 100644 index 000000000..a5fe73599 --- /dev/null +++ b/.ai/checklists/performance.md @@ -0,0 +1,65 @@ +# Checklist: Performance + +Run when the change adds a query, iterates over entities, touches a page-render path, or changes caching. + +Complementary to `.ai/knowledge/performance.md` and `.ai/knowledge/caching.md`. + +--- + +## Queries + +- [ ] No query inside a loop. Fetch the set once and join in memory, or push the filter into the query. +- [ ] Filtering, sorting, and paging happen in the query — not after `ToList()`. +- [ ] The query projects the fields it needs when the entity is large and only a few are used. +- [ ] List endpoints are paged. An unbounded list over a growing collection is a future outage. +- [ ] Existence checks use a count or an any-style query, not "load everything and check `Count`". +- [ ] New query shapes are supported by an index, or the absence of one is stated deliberately. + +## Writes + +- [ ] Updating a few fields uses a partial update rather than rewriting the whole document. +- [ ] Bulk operations are batched rather than issued one document at a time. +- [ ] No read-modify-write loop that could be a single update. + +## Caching + +- [ ] Data that is read far more than written, and is expensive to produce, is cached. +- [ ] The cache key contains every variable that changes the result — store, language, currency, customer group, vendor, page. +- [ ] Cached values are invalidated on **every** write path, including delete. +- [ ] Cross-family caches that embed this data are invalidated too. +- [ ] Nothing customer-specific is cached under a key that omits the customer. +- [ ] `Clear()` is not used to fix a stale entry — it evicts every store's cache. +- [ ] Caching sits in the business service, not in a controller or handler. + +## Render path + +- [ ] No repository or service call from a Razor view. +- [ ] A view component that loads data is not invoked inside a loop over products. +- [ ] View-model preparation happens once in the handler, not per item. +- [ ] Images carry `loading` and dimension attributes as the surrounding views do. +- [ ] No new blocking external HTTP call on a page-render path. If one is unavoidable, it has a timeout and a fallback. + +## Async and concurrency + +- [ ] Nothing blocks on a `Task`. +- [ ] Independent awaits that could run concurrently are not serialized in a loop when the underlying calls are safe to parallelize. +- [ ] Long work is moved to a scheduled task rather than run inside a request. +- [ ] Notification handlers are fast — they run inline in the write path. + +## Allocation + +- [ ] No repeated string concatenation in a loop where a builder is available. +- [ ] Large collections are not copied repeatedly between list types. +- [ ] Nothing large is held in a singleton or static field. + +## Scale assumptions + +- [ ] The change was reasoned about with a realistic catalog: tens of thousands of products, not ten. +- [ ] Behavior under multiple stores considered — per-store caching multiplies memory. +- [ ] Behavior under multiple application instances considered — cache invalidation must propagate. + +## Evidence + +- [ ] Any optimization that costs readability is justified by a measurement, not intuition. +- [ ] The measurement, or its absence, is stated in the PR. +- [ ] Nothing was optimized speculatively at the expense of clarity. diff --git a/.ai/checklists/plugin-release.md b/.ai/checklists/plugin-release.md new file mode 100644 index 000000000..97ce9cfc9 --- /dev/null +++ b/.ai/checklists/plugin-release.md @@ -0,0 +1,74 @@ +# Checklist: Plugin and Theme Release + +Run before shipping a new plugin or theme, or a version of one. + +Domain rules live in the per-kind skills; this is the packaging and lifecycle gate. + +--- + +## Identity + +- [ ] `SystemName` identical in `Manifest.cs`, `{Feature}Defaults`, the provider, and the output folder name. +- [ ] `SystemName` follows `{Group}.{Name}`, and `Group` is an existing group value. +- [ ] `SystemName` unchanged from the previously shipped version — it is the persisted identity. +- [ ] `Version` in the manifest bumped. +- [ ] `FriendlyName` in `{Feature}Defaults` is a **resource key**, and the provider resolves it through `ITranslationService`. + +## Project + +- [ ] Imports `..\..\Build\Grand.Common.props`. +- [ ] Correct SDK: `Microsoft.NET.Sdk.Razor` with `AddRazorSupportForMvc=true` and `StaticWebAssetsEnabled=false` if it has views; `Microsoft.NET.Sdk` otherwise. +- [ ] Output path set for **both** Debug and Release, to `Grand.Web/Plugins/{SystemName}/`. +- [ ] All GrandNode project references `false`, with `ExcludeAssets` matching the nearest comparable plugin. +- [ ] Package references carry no inline version. +- [ ] Added to `GrandNode.sln`. +- [ ] `logo.jpg` present and copied to output. +- [ ] Themes only: `Content/theme.jpg` present, and `Content/**` copied with `PreserveNewest`. + +## Registration + +- [ ] `IStartupApplication` registers the provider(s) with the right lifetime. +- [ ] `Priority` matches the convention for comparable plugins. +- [ ] `Configure` is empty unless the plugin owns middleware or endpoints. +- [ ] Nothing was added to `Program.cs` or to a core project on the plugin's behalf. + +## Install and uninstall + +- [ ] `Install()` saves default settings and adds every resource key, then calls `base.Install()` **last**. +- [ ] `Uninstall()` deletes settings and removes **every** key `Install()` added, then calls `base.Uninstall()` last. +- [ ] The two lists were diffed against each other, key by key. +- [ ] Install and uninstall touch nothing outside the plugin's own settings and resources. +- [ ] Install → uninstall → install leaves the store in its original state. + +## Configuration screen + +- [ ] `ConfigurationUrl` matches the admin controller's actual route. +- [ ] `[AuthorizeAdmin]`, `[Area("Admin")]`, `[PermissionAuthorize(...)]` all present. +- [ ] Store scope from `IAdminStoreService.GetActiveStore()` used on both GET and POST. +- [ ] `LoadSetting` before mutation, so untouched fields are not reset. +- [ ] `ClearCache()` after save. +- [ ] Invalid model state re-renders instead of saving. +- [ ] `_ViewImports.cshtml` and `_ViewStart.cshtml` present under the plugin's view folders. + +## Behavior + +- [ ] `LimitedToStores` and `LimitedToGroups` behave as documented. +- [ ] `Priority` is driven by a `DisplayOrder` setting the operator controls. +- [ ] Themes only: `GetViewLocations()` ends with the two default fallbacks, and `ThemeName` matches the `Views/` folder. +- [ ] Widgets only: zone names match the strings actually used in the target views. +- [ ] Consent gating present where the plugin injects third-party scripts. + +## Verification in a running store + +- [ ] Build output lands in `Grand.Web/Plugins/{SystemName}/` for Release as well as Debug. +- [ ] Plugin appears in the admin plugin list with its logo and friendly name. +- [ ] Install succeeds on a store that has existing data. +- [ ] Configuration screen loads, saves, and the saved value takes effect on the storefront. +- [ ] The feature works on a second store with different settings. +- [ ] Uninstall succeeds and leaves no orphaned settings or resources. + +## Documentation + +- [ ] PR states what the plugin does, which providers it registers, and which settings it adds. +- [ ] Breaking changes stated truthfully — including any renamed system name or removed setting. +- [ ] Themes only: the list of copied views and the upstream revision they were copied at. diff --git a/.ai/checklists/security.md b/.ai/checklists/security.md new file mode 100644 index 000000000..de65afc7f --- /dev/null +++ b/.ai/checklists/security.md @@ -0,0 +1,77 @@ +# Checklist: Security + +Run when the change touches authentication, authorization, user input, scoped data, secrets, payments, or file handling. + +Complementary to `.ai/skills/security-review.md` (procedure) and `.ai/knowledge/security.md` (patterns). + +--- + +## Authorization + +- [ ] Every admin controller carries `[AuthorizeAdmin]`, `[Area("...")]`, and `[PermissionAuthorize(...)]`. +- [ ] The permission used is the right one — not a broader permission that happened to be at hand. +- [ ] New actions on an existing controller are covered by the class-level attribute, or carry their own. +- [ ] Authorization is enforced in the controller, not only by hiding a link in the view. +- [ ] A new permission has a `PermissionProvider` entry **and** a migration, so existing installations receive it. + +## Trust boundaries + +- [ ] Ids arriving in a request (`storeId`, `vendorId`, `customerId`, entity ids) are re-checked against server-side context before any write. +- [ ] A vendor cannot read or modify another vendor's records by changing an id. +- [ ] A customer cannot reach another customer's orders, addresses, downloads, or documents. +- [ ] A store owner cannot reach another store's data. +- [ ] Mass-assignment is bounded — the bound model does not expose fields the caller must not set. + +## Input handling + +- [ ] Every model crossing the boundary has a validator covering the new fields. +- [ ] Guard clauses on public service methods. +- [ ] No query built by concatenating user input. +- [ ] Uploaded files: extension and content type checked, size bounded, filename not used as a path. +- [ ] Redirect targets are validated — no open redirect from a returned URL parameter. + +## Output + +- [ ] User-supplied content is encoded by default. +- [ ] `Html.Raw` is used only on operator-authored or already-sanitized content. +- [ ] Error messages returned to the customer do not disclose internal paths, ids, or stack traces. +- [ ] Database-sourced HTML that may contain `{{ }}` is `v-pre`-guarded so it is not compiled as a Vue template. + +## Secrets and sensitive data + +- [ ] No credential, API key, connection string, or pepper committed. +- [ ] Secrets read from configuration, not constants. +- [ ] No secret, token, password, card data, or full personal record in a log message or an exception message. +- [ ] Personal data exposure respects the customer's consent settings where the feature is consent-gated. + +## Authentication + +- [ ] Password handling goes through the central verification path — no ad-hoc hashing or comparison. +- [ ] Failed login returns a result value and does not disclose whether the account exists. +- [ ] Session, cookie, and two-factor behavior unchanged unless that is the point of the change. +- [ ] External authentication tokens are not logged or persisted beyond what the flow requires. + +## Web hygiene + +- [ ] Forms and AJAX mutations carry antiforgery tokens. +- [ ] State-changing endpoints are POST, not GET. +- [ ] No third-party script added without a consent gate where one is required. +- [ ] No external CDN reference introduced in a storefront view. + +## Payments + +- [ ] No card data stored or logged. +- [ ] Amounts are re-derived server-side, never taken from the request. +- [ ] Provider callbacks verify their signature or shared secret before acting. +- [ ] A repeated callback cannot double-apply a payment. + +## Plugins + +- [ ] A plugin cannot escalate privilege through the services it registers. +- [ ] `Install()` / `Uninstall()` do not touch data outside the plugin's own settings and resources. +- [ ] Provider `LimitedToStores` / `LimitedToGroups` are honoured by consumers of the provider. + +## Verification + +- [ ] Each finding here was confirmed by reading the code, not inferred from a name. +- [ ] Anything that could not be verified is stated as unverified in the PR. diff --git a/.ai/constraints.md b/.ai/constraints.md new file mode 100644 index 000000000..ac56cb0c6 --- /dev/null +++ b/.ai/constraints.md @@ -0,0 +1,161 @@ +# Constraints + +Hard prohibitions. Unlike `.ai/principles.md`, these are not judgment calls — a violation is a defect, and a reviewer should reject it without debating trade-offs. + +Each entry states the prohibition, the reason, and what to do instead. When a constraint has a legitimate exception, the exception is listed; there are no unlisted exceptions. + +--- + +## Dependencies + +### Never add a NuGet package version in a `.csproj` +Central package management is on (`ManagePackageVersionsCentrally=true`). An inline version is a build error, not a style issue. +→ Add `` to `Directory.Packages.props`, reference without a version. + +### Never add a package for a capability the repository already has +MediatR, FluentValidation, `Grand.Mapping`, `MongoDB.Driver`, `StackExchange.Redis`, DotLiquid, Scryber, ImageSharp, MailKit, Scrutor are already present. Adding a second library for the same job splits the codebase. +→ See the table in `.ai/standards/dependencies.md`. + +### Never use Newtonsoft.Json +It is not referenced anywhere. `System.Text.Json` is the serializer. +→ `System.Text.Json`, or MessagePack where the existing code uses it. + +### Never use AutoMapper +The package is **not** referenced, despite `Grand.Mapping` exposing an AutoMapper-compatible `Profile` / `CreateMap` / `ForMember` API. Adding it would silently shadow the in-house mapper. +→ `Grand.Mapping`, via an `IAutoMapperProfile` implementation. + +### Never reference a plugin from core, business, or web projects +Dependencies point inward. A core project that knows a plugin exists cannot be built without it. +→ Define an interface in core; the plugin registers an implementation. + +--- + +## Async + +### Never block on a `Task` +No `.Result`, no `.Wait()`, no `.GetAwaiter().GetResult()` in request or service code. Under load this deadlocks or exhausts the thread pool. +→ `await` all the way down. The handful of existing occurrences are in startup paths and are not a precedent. + +### Never write `async void` +There are zero in the codebase. An exception in an `async void` method cannot be caught by the caller and takes down the process. +→ `async Task`. For event handlers, use `INotificationHandler` which is already `Task`-returning. + +### Never use `Task.Run` to make sync code look async +It moves work to another thread without removing the blocking, and loses the request context. +→ Make the underlying call async, or leave it synchronous. + +### Never swallow a `CancellationToken` +If the surrounding signatures carry one, forward it. + +--- + +## Data and persistence + +### Never inject `IMongoDatabase` or a Mongo collection into a business service +It ties the business layer to the driver and makes the service untestable. +→ `IRepository`. Mongo-specific behavior belongs in `Grand.Data`. + +### Never build a query from string concatenation of user input +→ Typed LINQ over `IRepository.Table`, or the repository's filter helpers. + +### Never filter scoped data in memory after materializing it +Loading every store's records and filtering in C# is both a performance defect and a leak waiting for the filter to be dropped. +→ Filter in the query. See `.ai/knowledge/scoping.md`. + +### Never write an entity without invalidating its cache and publishing its event +A cached read that survives its write serves stale — sometimes deleted — data. +→ `RemoveByPrefix(CacheKey.*_PATTERN_KEY)` then `_mediator.EntityInserted/Updated/Deleted`. See `.ai/examples/cached-store-scoped-service.md`. + +### Never omit a result-changing variable from a cache key +Store id, language id, customer group, currency, vendor id, page index. A missing store id is a cross-store data leak. + +### Never reuse a migration `Identity` GUID +The runner uses it to record what already ran; a duplicate means one migration silently never executes. +→ Generate a new GUID. + +### Never let a migration throw +It aborts the whole upgrade. +→ Catch and return `false`. + +--- + +## Time, culture, and formatting + +### Never use `DateTime.Now` +Stores span time zones; the server's local time is meaningless. The codebase uses `DateTime.UtcNow` almost everywhere (189 occurrences against 7). +→ `DateTime.UtcNow`, converted for display only. + +### Never format or parse machine-readable values with the current culture +Prices, ids, and stored strings must not depend on the request's culture. +→ `CultureInfo.InvariantCulture` for machine-readable values; the working culture only for display. + +### Never hardcode a user-facing string +→ A translation resource, added in `Install()` or an upgrade XML, read via `ITranslationService` / `@Loc[...]`. + +--- + +## Web layer + +### Never put business logic in a controller +→ A MediatR command or query handler. + +### Never trust an id from a request +A posted `vendorId`, `storeId`, or `customerId` is attacker-controlled. +→ Re-check ownership against `IWorkContext` / the resolved store, server-side, before writing. + +### Never omit the authorization attribute on an admin controller +`[AuthorizeAdmin]`, `[Area("...")]`, and `[PermissionAuthorize(...)]` together. A missing permission attribute leaves the screen open to any admin. + +### Never register services in `Program.cs` +→ `IStartupApplication.ConfigureServices` in the owning project. + +### Never read `IWorkContext` from a scheduled task, migration, or plugin `Install()` +There is no request, so there is no ambient context — it is null or stale. +→ Take store, customer, and language as explicit parameters. + +### Never `Html.Raw` user-supplied content +→ Encode by default; `Html.Raw` only for content already sanitized or authored by an operator. + +### Never remove a widget zone from a view +It silently disables every installed widget on that page and is a breaking change for third-party plugins. + +--- + +## Reflection and dynamic code + +### Never add ad-hoc reflection +Reflection in this repository is deliberately confined to the infrastructure that owns it: `Grand.Infrastructure.TypeSearch` (assembly scanning for `IStartupApplication`, providers, mapper profiles, validators), plugin loading, and `Grand.Infrastructure.Roslyn`. That is a platform mechanism, not a general licence. + +New feature code must not use `Activator.CreateInstance`, `GetType().GetProperty(...)`, or `Type.GetType(name)` to reach behavior that a DI registration or an interface could express. +→ Register an implementation and inject the interface. If discovery is genuinely needed, use `ITypeSearcher` rather than writing new scanning. + +### Never use `dynamic` +It defers every error to runtime and defeats every tool. + +--- + +## Structure and hygiene + +### Never introduce static mutable state +It breaks multi-store isolation and makes tests order-dependent. +→ A scoped service. + +### Never change a shipped plugin `SystemName`, permission system name, message template name, or schedule task name +They are persisted identities. Renaming one orphans existing data in every installation. + +### Never weaken or delete a failing assertion to make a suite pass +→ Find out why it now fails. + +### Never leave commented-out code, `#region`, or a `TODO` without an issue number + +### Never commit `obj/`, `bin/`, `TestResults/`, `.vs/`, or `.idea/` +Generated frontend bundles **are** committed, alongside the source that produced them. + +### Never edit a generated bundle by hand +→ Change the source and rebuild. See `.ai/skills/frontend-bundle-workflow.md`. + +--- + +## When a constraint blocks legitimate work + +Say so explicitly in the PR, explain why the alternative does not work, and get agreement before violating it. A documented, argued exception is fine. A silent one is not. diff --git a/.ai/examples/README.md b/.ai/examples/README.md new file mode 100644 index 000000000..9a7dcb66f --- /dev/null +++ b/.ai/examples/README.md @@ -0,0 +1,13 @@ +# Examples + +Worked walkthroughs of code that already ships. Each one follows a single feature all the way through the layers, so the rules in `.ai/knowledge/` and `.ai/standards/` can be seen applied rather than only stated. + +| Example | Shows | +|---|---| +| `cached-store-scoped-service.md` | The canonical business-service shape: read-through cache, store scope, invalidation, entity events | +| `payment-plugin-walkthrough.md` | A complete plugin, file by file, from manifest to admin screen | +| `theme-override-walkthrough.md` | How `Theme.Modern` overrides a subset of views and what falls through | + +Every example names the real files it is drawn from. When an example and the shipped code disagree, the shipped code is correct — fix the example. + +These are illustrations, not templates. For copy-ready skeletons use `.ai/templates/`. diff --git a/.ai/examples/cached-store-scoped-service.md b/.ai/examples/cached-store-scoped-service.md new file mode 100644 index 000000000..047b788e9 --- /dev/null +++ b/.ai/examples/cached-store-scoped-service.md @@ -0,0 +1,135 @@ +# Example: Cached, Store-Scoped Business Service + +Source: `src/Business/Grand.Business.Catalog/Services/Tax/TaxCategoryService.cs` + +This is the canonical shape of a GrandNode business service. Almost every service in `Grand.Business.*` is a variation on it. Read it alongside `.ai/knowledge/caching.md`, `.ai/knowledge/scoping.md`, and `.ai/knowledge/domain-events.md`. + +--- + +## Dependencies + +```csharp +public class TaxCategoryService : ITaxCategoryService +{ + private readonly IRepository _taxCategoryRepository; + private readonly IMediator _mediator; + private readonly ICacheBase _cacheBase; + + public TaxCategoryService(ICacheBase cacheBase, + IRepository taxCategoryRepository, + IMediator mediator) + { + _cacheBase = cacheBase; + _taxCategoryRepository = taxCategoryRepository; + _mediator = mediator; + } +} +``` + +Three dependencies, and they are the three a service of this kind almost always has: **repository abstraction**, **cache**, **mediator**. No `IMongoDatabase`, no `IServiceProvider`, no `IWorkContext` — the store id arrives as a parameter. + +The class is registered `AddScoped` in the owning project's `IStartupApplication`. + +## Read: cache wraps the query, store id is in the key + +```csharp +public virtual async Task> GetAllTaxCategories(string storeId = "") +{ + var key = string.Format(CacheKey.TAXCATEGORIES_ALL_KEY, storeId); + return await _cacheBase.GetAsync(key, async () => + { + var query = _taxCategoryRepository.Table.AsQueryable(); + if (!string.IsNullOrEmpty(storeId)) + query = query.Where(tc => tc.StoreId == storeId || string.IsNullOrEmpty(tc.StoreId)); + return await Task.FromResult(query.OrderBy(tc => tc.DisplayOrder).ToList()); + }); +} +``` + +Four things at once: + +1. **The key constant carries the parameter.** `TAXCATEGORIES_ALL_KEY` is `"Grand.taxcategory.all-{0}"`, documented in `CommonCacheKey.cs` with `{0} : store ID (empty = all stores)`. +2. **The store id is part of the key.** Omit it and store A serves store B's tax categories. This is a data leak, not a cache miss. +3. **`GetAsync` is read-through** — the delegate only runs on a miss. There is no separate `SetAsync`. +4. **Scoping is applied in the query**, not after materialization: records with an empty `StoreId` are global and visible to every store; the rest match the current store. + +The by-id read has the same shape with a different parameter: + +```csharp +public virtual Task GetTaxCategoryById(string taxCategoryId) +{ + var key = string.Format(CacheKey.TAXCATEGORIES_BY_ID_KEY, taxCategoryId); + return _cacheBase.GetAsync(key, () => _taxCategoryRepository.GetByIdAsync(taxCategoryId)); +} +``` + +Note it returns `Task` directly without `async`/`await` — a pure pass-through, per `.ai/standards/csharp-style.md`. + +## Write: guard, write, invalidate, publish — in that order + +```csharp +public virtual async Task InsertTaxCategory(TaxCategory taxCategory) +{ + ArgumentNullException.ThrowIfNull(taxCategory); + + await _taxCategoryRepository.InsertAsync(taxCategory); + + await _cacheBase.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY); + + //event notification + await _mediator.EntityInserted(taxCategory); +} +``` + +The order is the contract: + +1. `ArgumentNullException.ThrowIfNull` — the built-in helper, not a hand-written null check. +2. Repository write. +3. `RemoveByPrefix` with the `*_PATTERN_KEY` constant, which clears every key in the family regardless of which store ids happen to be cached. +4. `EntityInserted` — published **after** the write succeeded, through the `IMediator` extension rather than by constructing the notification. + +`UpdateTaxCategory` and `DeleteTaxCategory` are identical in structure with `EntityUpdated` / `EntityDeleted`. All three invalidate. A service that caches on read but forgets to invalidate on delete serves deleted records until the entry expires. + +## Cross-family invalidation + +Delete does one extra thing: + +```csharp +await _taxCategoryRepository.DeleteAsync(taxCategory); + +//clear tax categories cache +await _cacheBase.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY); + +//clear product cache +await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_PATTERN_KEY); + +//event notification +await _mediator.EntityDeleted(taxCategory); +``` + +Products embed a tax category, so cached product projections are stale the moment a tax category disappears. **When you add a cached family, ask what else embeds this data in a cached projection, and clear those prefixes too.** This is the step most often missed in review. + +## `virtual` methods + +Every public method is `virtual`. That is deliberate — plugins replace core services by registering their own implementation, and derived implementations override individual methods. Keep new service methods `virtual` for consistency with the surrounding code. + +## What is deliberately absent + +| Not here | Where it belongs | +|---|---| +| View models | `Grand.Web/Models` + a MediatR handler | +| `IWorkContext` | the caller — the service takes `storeId` explicitly | +| MongoDB filters and collections | inside `IRepository` | +| Authorization checks | the controller's `[PermissionAuthorize]` attribute | +| Try/catch around the write | nothing here is an expected failure | + +## Checklist when writing a service like this + +- [ ] Constructor takes `IRepository`, `ICacheBase`, `IMediator` — nothing infrastructural. +- [ ] Every cached read uses a `CacheKey` constant with every result-changing parameter formatted in. +- [ ] Store scope is applied inside the query. +- [ ] Every write invalidates with `RemoveByPrefix` and the `*_PATTERN_KEY` constant. +- [ ] Every write publishes the matching entity event, after the write. +- [ ] Cross-family caches that embed this entity are invalidated too. +- [ ] Guard clauses use `ArgumentNullException.ThrowIfNull`. +- [ ] Methods are `virtual`. diff --git a/.ai/examples/payment-plugin-walkthrough.md b/.ai/examples/payment-plugin-walkthrough.md new file mode 100644 index 000000000..901615455 --- /dev/null +++ b/.ai/examples/payment-plugin-walkthrough.md @@ -0,0 +1,211 @@ +# Example: Payment Plugin, File by File + +Source: `src/Plugins/Payments.CashOnDelivery/` + +The smallest complete GrandNode plugin that still has every part: manifest, defaults, settings, provider, plugin lifecycle, DI registration, storefront controller, and an admin configuration screen. Use it as the reference when scaffolding any plugin, not just payment ones. + +Read `.ai/skills/plugin-payment.md` for the payment-specific contract, and `.ai/templates/plugin/` for the copy-ready skeleton. + +--- + +## The tree + +``` +Payments.CashOnDelivery/ + Payments.CashOnDelivery.csproj + Manifest.cs + CashOnDeliveryPaymentDefaults.cs + CashOnDeliveryPaymentSettings.cs + CashOnDeliveryPaymentProvider.cs + CashOnDeliveryPaymentPlugin.cs + StartupApplication.cs + EndpointProvider.cs + logo.jpg + Controllers/PaymentCashOnDeliveryController.cs ← storefront + Models/ConfigurationModel.cs + Models/PaymentInfoModel.cs + Views/ + Areas/Admin/Controllers/PaymentCashOnDeliveryController.cs + Areas/Admin/Views/_ViewImports.cshtml + Areas/Admin/Views/_ViewStart.cshtml + Areas/Admin/Views/PaymentCashOnDelivery/Configure.cshtml +``` + +Note the two controllers with the same name in different namespaces — one storefront, one admin. That is the convention, not an accident. + +## 1. Identity lives in one place + +```csharp +public static class CashOnDeliveryPaymentDefaults +{ + public const string ProviderSystemName = "Payments.CashOnDelivery"; + public const string FriendlyName = "Payments.CashOnDelivery.FriendlyName"; + public const string ConfigurationUrl = "/Admin/PaymentCashOnDelivery/Configure"; +} +``` + +Three constants, referenced everywhere else: + +- `ProviderSystemName` → the manifest's `SystemName`, the provider's `SystemName`, and the output folder. All three must agree; the system name is the plugin's persisted identity and cannot change after release. +- `FriendlyName` is a **resource key**, not a display string. The provider resolves it through `ITranslationService`. +- `ConfigurationUrl` must match the admin controller's actual route, or the admin's *Configure* link 404s. + +The manifest points back at the constant: + +```csharp +[assembly: PluginInfo( + FriendlyName = "Cash On Delivery (COD)", + Group = "Payment methods", + SystemName = CashOnDeliveryPaymentDefaults.ProviderSystemName, + Author = "grandnode team", + Version = "1.0.0")] +``` + +## 2. The provider: `IProvider` members first + +```csharp +public class CashOnDeliveryPaymentProvider : IPaymentProvider +{ + public CashOnDeliveryPaymentProvider( + ITranslationService translationService, + IHttpContextAccessor httpContextAccessor, + CashOnDeliveryPaymentSettings cashOnDeliveryPaymentSettings) + { … } + + public string ConfigurationUrl => CashOnDeliveryPaymentDefaults.ConfigurationUrl; + public string SystemName => CashOnDeliveryPaymentDefaults.ProviderSystemName; + public string FriendlyName => _translationService.GetResource(CashOnDeliveryPaymentDefaults.FriendlyName); + public int Priority => _cashOnDeliveryPaymentSettings.DisplayOrder; + public IList LimitedToStores => new List(); + public IList LimitedToGroups => new List(); + … +} +``` + +Points worth copying: + +- **The settings class is injected directly.** GrandNode registers `ISettings` implementations in DI — do not resolve them through `ISettingService` inside a provider. +- **`FriendlyName` goes through `ITranslationService`.** A literal here cannot be translated and cannot be changed by an operator. +- **`Priority` comes from `DisplayOrder`**, so operators control ordering from the admin screen. +- **`LimitedToStores` / `LimitedToGroups`** are the scoping hooks from `.ai/knowledge/scoping.md`. Returning empty lists means "available everywhere". + +The payment-specific members follow (`ProcessPayment`, `PostProcessPayment`, `PostRedirectPayment`, …). COD is the degenerate case — it returns `TransactionStatus.Pending` and does nothing else, which makes the surrounding structure easy to see. + +## 3. The plugin: install is a contract with uninstall + +```csharp +public class CashOnDeliveryPaymentPlugin( + ISettingService settingService, + IPluginTranslateResource pluginTranslateResource) + : BasePlugin, IPlugin +{ + public override string ConfigurationUrl() => CashOnDeliveryPaymentDefaults.ConfigurationUrl; + + public override async Task Install() + { + var settings = new CashOnDeliveryPaymentSettings { DescriptionText = "…" }; + await settingService.SaveSetting(settings); + + await pluginTranslateResource.AddOrUpdatePluginTranslateResource( + "Payments.CashOnDelivery.FriendlyName", "Cash on delivery"); + await pluginTranslateResource.AddOrUpdatePluginTranslateResource( + "Plugins.Payment.CashOnDelivery.DescriptionText", "Description"); + // … one Add per resource key, plus a .Hint for each admin field + await base.Install(); + } + + public override async Task Uninstall() + { + await settingService.DeleteSetting(); + await pluginTranslateResource.DeletePluginTranslationResource( + "Plugins.Payment.CashOnDelivery.DescriptionText"); + // … one Delete per key Install added + await base.Uninstall(); + } +} +``` + +- Primary constructor syntax — the codebase uses it for plugin classes. +- `base.Install()` / `base.Uninstall()` mark the plugin installed/uninstalled and are called **last**. +- **Every key added in `Install` should be removed in `Uninstall`.** In the shipped file `Uninstall` misses `DisplayOrder` and `FriendlyName` — an example of the drift this rule exists to prevent. Do not copy that gap. +- Admin fields get two keys each: the label and a `.Hint`. + +## 4. Registration + +```csharp +public class StartupApplication : IStartupApplication +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + } + + public int Priority => 10; + public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) { } + public bool BeforeConfigure => false; +} +``` + +Discovered by assembly scanning — nothing in `Program.cs` or `Grand.Web` mentions this plugin. `Priority => 10` is the plugin convention. `Configure` stays empty because the plugin owns no middleware; routes come from `EndpointProvider.cs` instead. + +## 5. Admin configuration: store scope is the whole point + +```csharp +[AuthorizeAdmin] +[Area("Admin")] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class PaymentCashOnDeliveryController : BasePaymentController +{ + public async Task Configure() + { + var storeScope = await _adminStoreService.GetActiveStore(); + var settings = await _settingService.LoadSetting(storeScope); + var model = new ConfigurationModel { … , ActiveStore = storeScope }; + return View(model); + } + + [HttpPost] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var storeScope = await _adminStoreService.GetActiveStore(); + var settings = await _settingService.LoadSetting(storeScope); + settings.DescriptionText = model.DescriptionText; + // … + await _settingService.SaveSetting(settings, storeScope); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + return await Configure(); + } +} +``` + +Five things that are each a bug when omitted: + +1. All three attributes. Without `[PermissionAuthorize]` any admin can reconfigure payments. +2. `GetActiveStore()` on **both** GET and POST. Saving without the scope overwrites the global value for every store. +3. `LoadSetting` before mutating, so untouched properties are not reset to type defaults. +4. `ClearCache()` after saving, or the storefront keeps the old settings. +5. Invalid model state re-renders the form rather than saving partial data. + +Views under `Areas/Admin/Views/` need their own `_ViewImports.cshtml` (tag helpers + `@inject LocService Loc`) and a `_ViewStart.cshtml` with `Layout = ""` — the admin supplies the shell. + +## 6. Project file + +`Microsoft.NET.Sdk.Razor` with `AddRazorSupportForMvc=true` and `StaticWebAssetsEnabled=false`; output path set for **both** Debug and Release to `..\..\Web\Grand.Web\Plugins\Payments.CashOnDelivery\`; every GrandNode reference `false`, with `all` on `Grand.Web.Common`; `logo.jpg` copied to output. + +A Release output path missing means the plugin silently disappears from release builds. + +## Trace: what happens at runtime + +1. Host starts → assembly scan finds `StartupApplication` → `IPaymentProvider` registered. +2. Plugin marked installed → `Install()` seeded settings and resource keys. +3. Checkout asks for available payment providers → this one appears, ordered by `Priority`, filtered by `LimitedToStores` / `LimitedToGroups`. +4. Its name renders through `FriendlyName` → `ITranslationService` → the resource key installed in step 2. +5. Operator clicks *Configure* → `ConfigurationUrl` → admin controller → settings loaded for the active store. +6. Customer selects it → `ProcessPayment` → `TransactionStatus.Pending`. diff --git a/.ai/examples/theme-override-walkthrough.md b/.ai/examples/theme-override-walkthrough.md new file mode 100644 index 000000000..0653a0d95 --- /dev/null +++ b/.ai/examples/theme-override-walkthrough.md @@ -0,0 +1,139 @@ +# Example: Theme Override + +Source: `src/Plugins/Theme.Modern/` + +How a GrandNode theme replaces part of the storefront without forking it. Read `.ai/skills/theme-creation.md` for the contract and `.ai/templates/theme/theme-plugin.md` for the skeleton. + +--- + +## Five files and a folder + +``` +Theme.Modern/ + Theme.Modern.csproj + Manifest.cs ← Group = "Themes" + ModernThemePlugin.cs ← one line + ModernThemeView.cs ← the whole mechanism + StartupApplication.cs ← one registration + logo.jpg + Content/{css,script,images,swiper}, theme.jpg + Views/Modern/… ← the overrides +``` + +The plugin class is a declaration and nothing more: + +```csharp +public class MinimalThemePlugin : BasePlugin, IPlugin; +``` + +A theme with no settings and no resource keys needs no `Install()` or `Uninstall()` — `BasePlugin` already marks it installed. (The class name here does not match the file name `ModernThemePlugin.cs`; the type name is not load-bearing, but new code should keep them aligned per `.ai/standards/naming.md`.) + +Registration is equally small: + +```csharp +public void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + services.AddScoped(); +} +``` + +## The mechanism: `GetViewLocations()` + +```csharp +public class ModernThemeView : IThemeView +{ + public string AreaName => ""; + public string ThemeName => "Modern"; + + public ThemeInfo ThemeInfo => new("Modern theme (beta)", + "~/Plugins/Theme.Modern/Content/theme.jpg", "Minimal theme (beta)", false); + + public IEnumerable GetViewLocations() + { + return new List { + "/Views/Modern/{1}/{0}.cshtml", + "/Views/Modern/Shared/{0}.cshtml", + "/Views/{1}/{0}.cshtml", + "/Views/Shared/{0}.cshtml" + }; + } +} +``` + +`{0}` is the view name, `{1}` the controller name. Resolution walks the list in order and takes the first hit: + +| Request | Resolves to | +|---|---| +| `Product/ProductTemplate.Simple` (theme has it) | `/Views/Modern/Product/ProductTemplate.Simple.cshtml` | +| `Vendor/List` (theme does not) | `/Views/Vendor/List.cshtml` — the default in `Grand.Web` | + +The last two entries are the fallback. **Remove them and every page the theme has not copied fails with "view not found."** That single detail is what makes a theme an override set rather than a fork. + +`ThemeName` is `"Modern"` and the folder is `Views/Modern/` — those must match, because the format strings hardcode the folder name. + +## What "a subset" means in practice + +`Theme.Modern` ships 110 `.cshtml` files against 235 in `Grand.Web/Views`: + +| Folder | Views | +|---|---| +| `Shared/` | 47 | +| `Account/` | 18 | +| `Product/` | 16 | +| `Catalog/` | 11 | +| `Blog/` | 5 | +| `Checkout/`, `News/`, `Order/` | 2 each | +| `Common/`, `Home/`, `MerchandiseReturn/`, `ShoppingCart/`, `Wishlist/` | 1 each | + +The weight is in `Shared/` — layouts, partials, and components, the markup that defines the theme's look. Everything not listed (vendor pages, courses, knowledgebase, most of checkout) falls through to the default views and stays correct for free. + +**That distribution is the lesson.** Start from layouts and shared partials. Copy a page-level view only when its structure genuinely differs; if only styling differs, a rule in `Content/css/` is cheaper and does not need re-reconciling on upgrade. + +## The `_ViewImports.cshtml` trap + +The theme's view folder does **not** inherit `Grand.Web`'s imports. `Views/Modern/_ViewImports.cshtml` re-declares everything — the tag helper registrations, ~30 `@using` lines for `Grand.Web.Models.*` / `Grand.Domain.*` / `Grand.Web.Common.*`, and `@inject LocService Loc`. + +It also carries this, which is easy to lose when writing a new theme by hand: + +```cshtml +@*we remove the default InputTagHelper to prevent the checkbox duplicating*@ +@removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.InputTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers +``` + +Without it, every checkbox in the theme renders twice. Copy the file wholesale rather than assembling the `@using` list from scratch — a missing namespace only surfaces when some copied view fails to compile. + +## Assets + +`Content/` is grouped by area — `common/`, `header/`, `home/`, `catalog/`, `product/`, `cart/`, `customer/`, `blog-news/`, `fonts/` — plus `script/` and a vendored `swiper/`. The whole tree is copied with `PreserveNewest` and served from `~/Plugins/Theme.Modern/Content/...`. + +`Content/theme.jpg` is what `ThemeInfo.PreviewImageUrl` points at — the image the admin theme picker shows. `logo.jpg` is the separate plugin-list logo. Both are required. + +## Project file, the theme-specific parts + +```xml + + + true + false + + … + + false + all + +``` + +A theme is the one plugin kind that references `Grand.Web` — it compiles against the storefront view models. `ExcludeAssets=all` keeps the assembly out of the plugin's output; the host already has it. + +Output path is set for both Debug and Release to `..\..\Web\Grand.Web\Plugins\Theme.Modern\`. + +## The upgrade cost + +Every copied view is a file that must be reconciled against upstream on each GrandNode upgrade. A view that drifted silently — a widget zone dropped, a `data-cart-action` attribute lost, a model property renamed — is the standard theme failure mode. + +Mitigations, in order of value: + +1. Copy fewer views. Prefer CSS. +2. Record which views were copied, at which upstream revision, in the PR. +3. On upgrade, diff each copied view against its `Grand.Web` counterpart before assuming it still works. +4. Never change a view model or route from inside a theme — that turns an override into a fork. diff --git a/.ai/glossary/README.md b/.ai/glossary/README.md new file mode 100644 index 000000000..b63463cf8 --- /dev/null +++ b/.ai/glossary/README.md @@ -0,0 +1,21 @@ +# Glossary + +The vocabulary of the GrandNode domain, mapped to the types that implement it. + +Read this before naming anything, before writing a model, and before assuming a term means what it means in another e-commerce platform. GrandNode descends from nopCommerce and renamed a large part of the vocabulary — using the old word in a new type name produces code that reads as if it belongs to a different system. + +| File | Covers | +|---|---| +| `entity-model.md` | Base types, marker interfaces, and the mechanics every entity shares | +| `catalog.md` | Products, grouping, attributes, pricing, inventory | +| `sales.md` | Cart, orders, payment, shipping, returns, discounts | +| `customers.md` | Customers, groups, vendors, sales employees | +| `platform.md` | Stores, localization, settings, permissions, CMS, media, messaging | +| `renamed-terms.md` | Terms that differ from nopCommerce and other platforms — read this first | + +## Rules + +1. Use the domain term the codebase uses. A "return request" is a **merchandise return**; a "customer role" is a **customer group**. +2. The domain term is also the type name. `Grand.Domain.Orders.MerchandiseReturn` — no `Entity`, `Model`, or `Dto` suffix in the domain layer. +3. When a glossary entry and the shipped entity disagree, the entity is correct — fix the glossary. +4. New domain concepts get a glossary entry in the same change that introduces them. diff --git a/.ai/glossary/catalog.md b/.ai/glossary/catalog.md new file mode 100644 index 000000000..bee1096b9 --- /dev/null +++ b/.ai/glossary/catalog.md @@ -0,0 +1,96 @@ +# Glossary: Catalog + +Source: `src/Core/Grand.Domain/Catalog/` + +--- + +## Product + +`Product` is the sellable unit. Its kind is `ProductTypeId` (`ProductType`): + +| Value | Meaning | +|---|---| +| `SimpleProduct` (0) | One sellable item — the default | +| `GroupedProduct` (10) | A parent listing whose children are the sellable products | +| `Reservation` (20) | Booked by date/time slot — rooms, appointments (`ProductReservation`) | +| `BundledProduct` (30) | Sold as a set of other products (`BundleProduct`) | +| `Auction` (40) | Sold by bidding (`Bid`) | + +Product type is not cosmetic — cart, pricing, and inventory branch on it. A change that touches purchasing must state which product types it was verified against. + +## Grouping + +Three independent ways to group products. They are not synonyms and they are not hierarchical variants of each other. + +| Term | Meaning | Link entity | +|---|---|---| +| **Category** | The navigational tree. Hierarchical, has a parent. | `ProductCategory` | +| **Brand** | The maker. Flat. (Called *Manufacturer* in other platforms.) | on the product | +| **Collection** | A curated cross-cutting set — "Summer 2026", "Staff picks". Flat. | `ProductCollection` | + +A product belongs to many categories and collections, and to at most one brand. + +## Attributes + +Two different mechanisms, constantly confused: + +| Term | Purpose | Types | +|---|---|---| +| **Product attribute** | Customer-selectable options that change what is bought — size, colour. Can change price, weight, SKU, and stock. | `ProductAttribute`, `ProductAttributeMapping`, `ProductAttributeValue`, `ProductAttributeCombination` | +| **Specification attribute** | Descriptive facts used for filtering and comparison — screen size, material. Never changes price or stock. | `SpecificationAttribute`, its options | + +- `ProductAttributeMapping` attaches an attribute to one product, with its control type (`AttributeControlType`). +- `ProductAttributeCombination` is a concrete combination (Red + Large) with its own SKU, stock, and price. +- `PredefinedProductAttributeValue` seeds values reused across products. + +If a change affects what the customer can buy, it is a product attribute. If it affects how they find or compare it, it is a specification attribute. + +## Pricing + +| Term | Meaning | +|---|---| +| **Tier price** | Quantity-break price on a product (`TierPrice`); per-combination variant `ProductCombinationTierPrices` | +| **Customer product price** | A price negotiated for one customer (`Grand.Domain.Customers.CustomerProductPrice`) | +| **Product price** | `ProductPrice` — per-currency price entries | +| **Catalog price rules** | Discounts of type catalog, applied through `Discount` — see `sales.md` | + +Prices are stored in the store's primary currency and converted for display. Never persist a converted value. + +## Inventory + +| Term | Meaning | +|---|---| +| **Manage inventory method** | `ManageInventoryMethod` — don't track / track by product / track by attribute combination | +| **Warehouse** | `Grand.Domain.Shipping.Warehouse` — stock location; per-combination stock in `ProductCombinationWarehouseInventory` | +| **Backorder mode** | `BackorderMode` — what happens at zero stock | +| **Low stock activity** | `LowStockActivity` — automatic reaction at the minimum threshold | +| **Inventory journal** | `InventoryJournal` — the movement log | +| **Out of stock subscription** | `OutOfStockSubscription` — customer notification request | + +Stock lives at the product, the combination, or the warehouse level depending on the inventory method. Code that adjusts stock must handle all three. + +## Layout + +`ProductLayout`, `CategoryLayout`, `BrandLayout`, `CollectionLayout` select which view renders the entity. **Layout**, never "template" — in this codebase a template is a message template or a Razor file. See `renamed-terms.md`. + +## Reviews and relations + +| Term | Meaning | +|---|---| +| **Product review** | `ProductReview` — customer rating and text, with approval | +| **Cross-sell product** | `CrossSellProduct` — "customers also bought", shown in the cart | +| **Related product** | Curated "you may also like", on the product page | +| **Also purchased** | `ProductAlsoPurchased` — computed from order history | +| **Product deleted** | `ProductDeleted` — tombstone kept so historical orders still resolve | + +`ProductDeleted` exists because orders reference products that operators remove. Never assume a product id in an order still resolves to a live `Product`. + +## Other + +| Term | Meaning | +|---|---| +| **Gift voucher** | `Grand.Domain.Orders.GiftVoucher` — prepaid value (called *gift card* elsewhere) | +| **Reservation** | `ProductReservation` — a bookable slot for a reservation product | +| **Bid** | `Bid` — an auction offer | +| **Customer group product** | `CustomerGroupProduct` — product visibility or ordering per customer group | +| **Customer tag product** | `CustomerTagProduct` — the same, driven by customer tags | diff --git a/.ai/glossary/customers.md b/.ai/glossary/customers.md new file mode 100644 index 000000000..a3b41a78e --- /dev/null +++ b/.ai/glossary/customers.md @@ -0,0 +1,71 @@ +# Glossary: Customers and Vendors + +Source: `src/Core/Grand.Domain/Customers/`, `Vendors/`, `Affiliates/` + +--- + +## Customer + +`Customer` is the account — and also the guest. A guest with items in a cart is a `Customer` record; registration converts it rather than creating a new one. Code that assumes "customer means registered" is wrong. + +| Term | Meaning | +|---|---| +| **Customer group** | `CustomerGroup` — a set of customers driving pricing, visibility, and permissions (called *customer role* elsewhere) | +| **System customer group names** | `SystemCustomerGroupNames` — the built-in groups (administrators, registered, guests, vendors). Match on these constants, never on the display name | +| **Customer tag** | `CustomerTag` — operator-assigned label for segmentation and targeting | +| **Customer attribute** | `CustomerAttribute` / `CustomerAttributeValue` — operator-defined registration fields | +| **Customer note** | `CustomerNote` — operator annotation | +| **Customer product** | `CustomerProduct` — the customer's relationship to a product (recently viewed, personal listing) | +| **Customer product price** | `CustomerProductPrice` — a negotiated price for one customer | +| **User field** | `UserField` on `BaseEntity` — sparse extension data (called *generic attribute* elsewhere) | +| **System customer field names** | `SystemCustomerFieldNames` — the well-known `UserField` keys. Never hardcode the string | + +Membership in a group is the authorization primitive. `CustomerGroup` drives `LimitedToGroups` on entities and providers — see `.ai/knowledge/scoping.md`. + +## Identity and authentication + +| Term | Meaning | +|---|---| +| **Password format** | `PasswordFormat` / `HashedPasswordFormat` — how the stored hash was produced; self-describing so old hashes can be upgraded on login | +| **Customer history password** | `CustomerHistoryPassword` — previous hashes, enforcing no-reuse policy | +| **External authentication** | `ExternalAuthentication` — a linked provider identity (Google, Facebook) | +| **Two factor authentication type** | `TwoFactorAuthenticationType` — app, email, or provider | +| **User API** | `UserApi` — API credentials for a customer, used by the API module | +| **Customer login results** | `CustomerLoginResults` — the enum a login attempt returns; not an exception | +| **User registration type** | `UserRegistrationType` — disabled, standard, email validation, admin approval | + +Login failure is an expected outcome and returns a result value. Do not convert it into an exception — see `.ai/principles.md`. + +## Vendor + +`Vendor` is a seller operating inside a store — a marketplace participant, not an operator. + +| Term | Meaning | +|---|---| +| **Vendor** | `Vendor` — the selling party; products carry its id | +| **Vendor note** | `VendorNote` — operator annotation | +| **Vendor review** | `VendorReview`, `VendorReviewHelpfulness` — customer feedback on the vendor | +| **Vendor settings** | `VendorSettings` — marketplace-wide vendor behavior | + +A vendor manager sees only their own records. Every vendor-facing query filters on `VendorId`, and every vendor-facing write re-checks ownership server-side. A posted id is attacker-controlled. See `.ai/knowledge/scoping.md`. + +`IWorkContext.CurrentVendor` is the logged-in vendor manager, and is null for ordinary customers and for background code. + +## Sales employee + +`SalesEmployee` is an internal staff member a customer can be assigned to — for commission and reporting. Distinct from a vendor (external seller) and from an administrator (an operator in the administrators customer group). + +## Affiliate + +`Grand.Domain.Affiliates.Affiliate` is a referrer credited for bringing in an order, tracked by URL parameter and stored on the order. Distinct from a vendor: an affiliate refers, a vendor sells. + +## Four parties, four boundaries + +| Party | Is | Sees | +|---|---|---| +| Customer | a buyer, possibly a guest | own orders, own data, one store | +| Vendor | an external seller | own products and orders, in stores they sell in | +| Sales employee | internal staff | assigned customers | +| Administrator | operator (a customer group) | everything their permissions allow | + +Each has its own admin area — see `.ai/knowledge/admin-areas.md`. Reusing a shared model across areas does not reuse the scope filter; each area applies its own. diff --git a/.ai/glossary/entity-model.md b/.ai/glossary/entity-model.md new file mode 100644 index 000000000..a824cb2c3 --- /dev/null +++ b/.ai/glossary/entity-model.md @@ -0,0 +1,79 @@ +# Glossary: Entity Model + +The mechanics every GrandNode entity shares. Source: `src/Core/Grand.Domain/`. + +--- + +## Base types + +| Type | Meaning | +|---|---| +| `ParentEntity` | The root of every persisted type. Owns `Id`, mapped to Mongo's `_id`. | +| `BaseEntity` | `ParentEntity` + `UserFields` + audit fields. **The default base for a top-level entity.** | +| `SubBaseEntity` | `ParentEntity` with nothing added — for documents embedded in another entity. | + +```csharp +public abstract class ParentEntity +{ + [DBFieldName("_id")] + public string Id { get; set; } // a new UniqueIdentifier when unset +} + +public abstract class BaseEntity : ParentEntity, IAuditableEntity +{ + public IList UserFields { get; set; } = new List(); + public DateTime CreatedOnUtc { get; set; } + public string CreatedBy { get; set; } + public DateTime? UpdatedOnUtc { get; set; } + public string UpdatedBy { get; set; } +} +``` + +**Id** is a `string`, not an `ObjectId` or an `int`. It is assigned at construction, so an entity has a valid id before it is saved. Never generate ids yourself, and never treat an empty id as "new" without checking. + +**Audit fields** (`IAuditableEntity`) are filled by the data layer's audit provider, not by service code. Do not set `CreatedOnUtc` or `UpdatedBy` by hand. + +## Marker interfaces + +These are the contract for cross-cutting behavior. Implementing one opts the entity into the corresponding machinery; forgetting one is why a feature silently does not work. + +| Interface | Members | Effect | +|---|---|---| +| `IStoreLinkEntity` | `LimitedToStores`, `Stores` | Entity can be restricted to a subset of stores | +| `IGroupLinkEntity` | `LimitedToGroups`, `CustomerGroups` | Entity can be restricted to customer groups | +| `ISlugEntity` | `SeName` | Entity has a URL slug, tracked in `EntityUrl` | +| `ITranslationEntity` | `Locales` (`IList`) | Entity has per-language property translations | +| `IAuditableEntity` | created/updated by/on | Audit stamping (already on `BaseEntity`) | + +Scoping semantics for the first two are in `.ai/knowledge/scoping.md`. Never filter on them in the view — filter in the query. + +## User fields + +`UserField` is the open extension point: a name/value pair with an optional store id, attached to any `BaseEntity`. It is how plugins attach data to core entities without changing the core schema. + +Use it for genuinely optional, sparse, per-installation data. Do **not** use it for a field the domain always has — that belongs on the entity — and do not query heavily on it. + +## Localized properties + +An entity implementing `ITranslationEntity` carries a `Locales` collection. The raw property holds the default-language value; per-language values live in `Locales`. + +Read through the translation extension with `IWorkContext.WorkingLanguage.Id` — never render the raw property directly, and never cache a localized projection without the language id in the key. + +Do not confuse this with `TranslationResource`, which holds UI strings (labels, messages) rather than entity content. + +## Slugs + +An `ISlugEntity` exposes `SeName`. The authoritative slug records live in `EntityUrl` (`Grand.Domain.Seo`), keyed by entity type and language. Changing a slug means writing an `EntityUrl` record — not just assigning `SeName`. + +## Persistence + +Entities are persisted through `IRepository` (`Grand.Data`). The business layer never sees a Mongo collection, filter, or driver type. `[DBFieldName]` (from `Grand.SharedKernel.Attributes`) maps a property to a different stored field name. + +## Rules + +1. A top-level entity derives from `BaseEntity`; an embedded document derives from `SubBaseEntity`. +2. `Id` is a `string` and is already populated — do not overwrite it on insert. +3. Implement the marker interfaces the feature needs; they are not optional decoration. +4. No UI concerns, no persistence details, and no dependencies on business or infrastructure in `Grand.Domain`. +5. Entities carry data and invariants, not repository or service calls. +6. Adding a field to an entity that already ships needs a default that preserves existing behavior, and usually a migration — see `.ai/templates/migration.md`. diff --git a/.ai/glossary/platform.md b/.ai/glossary/platform.md new file mode 100644 index 000000000..a434b502c --- /dev/null +++ b/.ai/glossary/platform.md @@ -0,0 +1,119 @@ +# Glossary: Platform + +Stores, localization, settings, permissions, content, media, and messaging. + +Source: `src/Core/Grand.Domain/Stores/`, `Localization/`, `Configuration/`, `Permissions/`, `Seo/`, `Pages/`, `Media/`, `Messages/` + +--- + +## Store + +| Term | Meaning | +|---|---| +| **Store** | `Store` — a storefront with its own hosts, currency, language, and settings | +| **Domain host** | `DomainHost` — a hostname routed to a store; how a request resolves its store | +| **Bank account** | `BankAccount` — store payment details shown on invoices | +| **Store link entity** | `IStoreLinkEntity` — the `LimitedToStores` / `Stores` marker | + +Multi-store is the default assumption, not a feature flag. The store is resolved **before** the customer, at the start of every request — see `.ai/knowledge/request-lifecycle.md`. + +## Localization + +| Term | Meaning | +|---|---| +| **Language** | `Language` — an installed language with culture and RTL flag | +| **Translation resource** | `TranslationResource` — one UI string, keyed (called *locale string resource* elsewhere) | +| **Translation resource area** | `TranslationResourceArea` — which surface a resource belongs to | +| **Translation entity** | `TranslationEntity` — a per-language value of an *entity property* | +| **Translation entity marker** | `ITranslationEntity` — the `Locales` collection | + +Two distinct things: + +- **Resources** are UI strings — labels, messages, validation text. Read through `ITranslationService` in services and `LocService` (`@Loc["..."]`) in views. Seeded from `App_Data/Resources/` and shipped through upgrade XML. +- **Translations on entities** are content — a product's name in German. Read through the translation extension with the working language id. + +A hardcoded user-facing string is a defect in both cases. + +## Settings + +| Term | Meaning | +|---|---| +| **Settings class** | any `ISettings` implementation — `CatalogSettings`, `OrderSettings`, … | +| **Setting service** | `ISettingService` — load/save, with per-store overrides | +| **Store scope** | the store a setting value applies to; empty means the global value | + +A setting has a global value and optional per-store overrides. Loading without a store id gives the global value, which is rarely what a storefront request wants. Some fields are deliberately system-wide and must be preserved when saving a store-scoped copy. See `.ai/skills/settings-and-localization.md`. + +Always `ClearCache()` after saving settings. + +## Permissions and navigation + +| Term | Meaning | +|---|---| +| **Permission** | a named capability granted to customer groups | +| **Permission system name** | `PermissionSystemName` — the stable identifier used in `[PermissionAuthorize]` | +| **Permission action name** | `PermissionActionName` — finer-grained action within a permission | +| **Standard permission** | `StandardPermission` — the built-in set | +| **Permission provider** | `PermissionProvider` — registers permissions at install | +| **Admin site map** | `AdminSiteMap` — the admin navigation tree | +| **Group link entity** | `IGroupLinkEntity` — `LimitedToGroups` / `CustomerGroups` | + +A new permission needs a provider entry **and** a migration, or existing installations never receive it. See `.ai/skills/permission-navigation.md`. + +## SEO + +| Term | Meaning | +|---|---| +| **Entity URL** | `EntityUrl` — the authoritative slug record, per entity and language | +| **SeName** | the slug property on `ISlugEntity` | +| **Entity types** | `EntityTypes` — which entity a slug belongs to | +| **Robots.txt** | `RobotsTxt` — operator-editable crawler rules | + +Changing a slug means writing an `EntityUrl` record, not just assigning `SeName`. + +## Content + +| Term | Meaning | +|---|---| +| **Page** | `Grand.Domain.Pages.Page` — an operator-authored content page (called *topic* elsewhere) | +| **Page layout** | `PageLayout` — which view renders it | +| **Blog** | `Grand.Domain.Blogs` — posts, categories, comments | +| **News** | `Grand.Domain.News` — news items and comments | +| **Knowledgebase** | `Grand.Domain.Knowledgebase` — articles and categories | +| **Course** | `Grand.Domain.Courses` — courses, lessons, subjects, tied to a product | +| **Document** | `Grand.Domain.Documents` — operator documents attached to customers or orders | + +Content authored in the admin is rendered with `Html.Raw` into a Vue-controlled page. Database content containing `{{ }}` is compiled as a Vue template unless wrapped in `v-pre` — see `.ai/standards/razor-frontend.md`. + +## Media + +| Term | Meaning | +|---|---| +| **Picture** | `Picture` — an image, stored in the database or on a configured provider | +| **Download** | `Download` / `DownloadType` — a downloadable file, e.g. for digital products | +| **Media settings** | `MediaSettings` — thumbnail sizes and image behavior | +| **Storage settings** | `StorageSettings` — where binaries live (DB, filesystem, S3, Azure Blob) | + +## Messaging + +| Term | Meaning | +|---|---| +| **Message template** | `MessageTemplate` — a DotLiquid email/notification body, keyed by name | +| **Message template names** | the constants message-sending code matches on | +| **Queued email** | the outbound message row; sending is asynchronous, via a scheduled task | +| **Token / drop** | the DotLiquid values a template may reference | +| **Message tokens added event** | the extension point for plugins to add tokens | + +A template may only use tokens the relevant drop exposes. See `.ai/skills/message-notification.md`. + +## Tasks and infrastructure + +| Term | Meaning | +|---|---| +| **Schedule task** | `Grand.Domain.Tasks.ScheduleTask` — the persisted definition; the DI key must equal `ScheduleTaskName` | +| **Migration** | `IMigration` — a versioned upgrade step, identified by a GUID | +| **DB version** | `MigrationDb` / `DbVersion` — the installed schema version | +| **GrandNode version** | `GrandNodeVersion` — the product version record | +| **History** | `Grand.Domain.History` — change tracking for auditable entities | + +Scheduled tasks and migrations run without a request, and therefore without `IWorkContext`. They take store and customer explicitly. diff --git a/.ai/glossary/renamed-terms.md b/.ai/glossary/renamed-terms.md new file mode 100644 index 000000000..36305cd9f --- /dev/null +++ b/.ai/glossary/renamed-terms.md @@ -0,0 +1,68 @@ +# Renamed Terms + +GrandNode descends from nopCommerce and renamed much of the vocabulary. Using the old term produces types that read as foreign to the codebase, and searches that find nothing. + +Read this before naming a type, a model property, a resource key, or a variable. + +| Elsewhere | In GrandNode | Type | +|---|---|---| +| Manufacturer | **Brand** | `Grand.Domain.Catalog.Brand` | +| — (new concept) | **Collection** | `Grand.Domain.Catalog.Collection` | +| Topic | **Page** | `Grand.Domain.Pages.Page` | +| Customer role | **Customer group** | `Grand.Domain.Customers.CustomerGroup` | +| Return request | **Merchandise return** | `Grand.Domain.Orders.MerchandiseReturn` | +| Reward points | **Loyalty points** | `Grand.Domain.Orders.LoyaltyPointsHistory` | +| Gift card | **Gift voucher** | `Grand.Domain.Orders.GiftVoucher` | +| Generic attribute | **User field** | `Grand.Domain.Common.UserField` | +| Locale string resource | **Translation resource** | `Grand.Domain.Localization.TranslationResource` | +| Localized property | **Translation entity** | `Grand.Domain.Localization.TranslationEntity` | +| URL record / slug record | **Entity URL** | `Grand.Domain.Seo.EntityUrl` | +| Product template | **Product layout** | `Grand.Domain.Catalog.ProductLayout` | +| Discount requirement | **Discount rule** | `Grand.Domain.Discounts.DiscountRule` | +| Specification attribute option | **Specification attribute option** | unchanged | +| Address attribute, checkout attribute | unchanged | `AddressAttribute`, `CheckoutAttribute` | + +## Layouts, not templates + +Every entity that has a selectable rendering has a `*Layout` type — `ProductLayout`, `CategoryLayout`, `BrandLayout`, `CollectionLayout`, `PageLayout`. "Template" in this codebase means a **message template** (`Grand.Domain.Messages.MessageTemplate`, DotLiquid) or a Razor view file, never a catalog rendering choice. + +## Two payment vocabularies + +`PaymentStatus` and `TransactionStatus` are different enums for different objects: + +- `Order.PaymentStatusId` → `Grand.Domain.Payments.PaymentStatus` — where the order stands commercially. +- `PaymentTransaction.TransactionStatus` → `Grand.Domain.Payments.TransactionStatus` — where one payment attempt stands with the provider. + +An order may have several payment transactions. Do not treat the two as interchangeable. + +## Groups, twice + +"Group" means two unrelated things depending on the namespace: + +- `CustomerGroup` — a set of customers, used for pricing, visibility, and permissions. +- `PluginInfo.Group` — the plugin category string in a manifest (`"Payment methods"`, `"Widgets"`, `"Themes"`). + +## Provider vs plugin + +- A **plugin** is the installable unit: an assembly, a manifest, an `IPlugin` implementation, an output folder. +- A **provider** is a capability the plugin registers: `IPaymentProvider`, `IShippingRateCalculationProvider`, `IWidgetProvider`, `IDiscountProvider`, `IThemeView`. + +One plugin may register several providers. `SystemName` on the provider and `SystemName` in the manifest must match — see `.ai/standards/naming.md`. + +## Store vs shop vs site + +The codebase says **store** (`Grand.Domain.Stores.Store`) — a storefront with its own domain hosts, currency, language, and settings. "Shop", "site", and "tenant" appear nowhere; do not introduce them. + +## Words to avoid entirely + +| Do not write | Because | +|---|---| +| `Manufacturer` | it is `Brand` | +| `Topic` | it is `Page` | +| `CustomerRole` | it is `CustomerGroup` | +| `ReturnRequest` | it is `MerchandiseReturn` | +| `RewardPoints` | it is `LoyaltyPoints` | +| `GiftCard` | it is `GiftVoucher` | +| `GenericAttribute` | it is `UserField` | +| `Tenant` | it is `Store` | +| `Repository` as a type-name suffix on a business service | the repository is `IRepository`; services are `*Service` | diff --git a/.ai/glossary/sales.md b/.ai/glossary/sales.md new file mode 100644 index 000000000..6a5aa0cae --- /dev/null +++ b/.ai/glossary/sales.md @@ -0,0 +1,109 @@ +# Glossary: Sales + +Cart through order, payment, shipping, returns, and discounts. + +Source: `src/Core/Grand.Domain/Orders/`, `Payments/`, `Shipping/`, `Discounts/` + +--- + +## Cart + +| Term | Meaning | +|---|---| +| **Shopping cart item** | `ShoppingCartItem` — a line held against the customer, not a separate cart document | +| **Shopping cart type** | `ShoppingCartType` — cart, wishlist, and the other list kinds share one entity | +| **Checkout attribute** | `CheckoutAttribute` — order-level options collected at checkout (gift wrap, delivery note), distinct from product attributes | + +There is no `Cart` entity. A cart is the set of `ShoppingCartItem` rows on a `Customer` filtered by `ShoppingCartType` and store. Code that "loads the cart" is filtering that collection — apply the store filter. + +## Order + +| Term | Meaning | +|---|---| +| **Order** | `Order` — the placed order; immutable in its commercial essentials once placed | +| **Order item** | `OrderItem` — one purchased line, holding the price *as sold* | +| **Order note** | `OrderNote` — operator or system annotation, optionally customer-visible | +| **Order tag** | `OrderTag` — operator-defined label for filtering | +| **Order tax** | `OrderTax` — the tax breakdown as computed at placement | + +`OrderItem` stores its own prices. Never recompute an order's totals from current product prices — the sold price is the record. + +### Three statuses, three questions + +| Enum | Question | Values | +|---|---|---| +| `OrderStatusSystem` | Where is the order in its lifecycle? | `Pending` (10), `Processing` (20), `Complete` (30), `Cancelled` (40) | +| `PaymentStatus` | Has it been paid? | pending / authorized / paid / refunded / voided | +| `ShippingStatus` | Has it shipped? | not required / not yet shipped / partially / shipped / delivered | + +They move independently. A `Complete` order can be partially refunded; a `Processing` order can be fully shipped. Never derive one from another. + +`OrderStatus` (alongside `OrderStatusSystem`) allows operator-defined statuses — read the system enum for logic, the operator status for display. + +`OrderItemStatus` tracks per-line state, which is what makes partial shipment and partial return possible. + +## Payment + +| Term | Meaning | +|---|---| +| **Payment transaction** | `PaymentTransaction` — one attempt against a provider | +| **Transaction status** | `TransactionStatus` — where that attempt stands | +| **Payment status** | `PaymentStatus` — where the *order* stands commercially | +| **Payment provider** | `IPaymentProvider` — the plugin capability | +| **Payment restriction** | `PaymentRestrictedSettings` — which methods are hidden for which countries/groups | + +An order may have several payment transactions (retry, capture, refund). `PaymentTransaction` is the audit trail; `Order.PaymentStatusId` is the summary. See `renamed-terms.md` for why they must not be conflated. + +Flows: **Standard** (charged in-process) vs **Redirection** (customer leaves to the provider and returns). Which one a plugin implements changes everything about its lifecycle — see `.ai/skills/plugin-payment.md`. + +## Shipping + +| Term | Meaning | +|---|---| +| **Shipping method** | `ShippingMethod` — the operator-facing choice ("Courier", "Economy") | +| **Shipping option** | `ShippingOption` — a computed quote returned by a provider, with a rate | +| **Shipment** | `Shipment` — an actual dispatch; an order can have many | +| **Shipment item** | `ShipmentItem` — which order items, in which quantity, from which warehouse | +| **Warehouse** | `Warehouse` — stock location | +| **Pickup point** | `PickupPoints` — collect-in-person location, an alternative to delivery | +| **Delivery date** | `DeliveryDate` — the promised-window label shown on a product | +| **Shipment tracker** | `IShipmentTracker` — maps a tracking number to carrier events | + +A *shipping method* is configuration; a *shipping option* is a runtime quote. Providers return options, never methods. + +## Returns + +| Term | Meaning | +|---|---| +| **Merchandise return** | `MerchandiseReturn` — the customer's request (called *return request* elsewhere) | +| **Merchandise return item** | `MerchandiseReturnItem` — which order items are coming back | +| **Merchandise return reason** | `MerchandiseReturnReason` — operator-defined reason list | +| **Merchandise return action** | `MerchandiseReturnAction` — what the customer wants: repair, replace, refund | +| **Merchandise return status** | `MerchandiseReturnStatus` — where the request stands | +| **Merchandise return note** | `MerchandiseReturnNote` — annotation on the request | + +A merchandise return is a request, not a refund. It does not change `PaymentStatus` by itself. + +## Discounts + +| Term | Meaning | +|---|---| +| **Discount** | `Discount` — the definition: type, amount or percentage, validity window | +| **Discount type** | `DiscountType` — what it applies to: order total, order subtotal, shipping, per-product, category, brand, collection | +| **Discount coupon** | `DiscountCoupon` — a code that activates a discount | +| **Discount rule** | `DiscountRule` — a condition the cart must satisfy (called *discount requirement* elsewhere); implemented by `IDiscountRule` in a plugin | +| **Discount limitation** | `DiscountLimitationType` — usage caps: unlimited, N times, N times per customer | +| **Discount usage history** | `DiscountUsageHistory` — the redemption log enforcing those caps | + +Multiple discounts can apply to one order. Never assume a single winner unless the discount type guarantees it. + +## Loyalty and vouchers + +| Term | Meaning | +|---|---| +| **Loyalty points** | `LoyaltyPointsHistory` — earned/spent ledger (called *reward points* elsewhere) | +| **Loyalty points settings** | `LoyaltyPointsSettings` — earn and redeem rates; some fields are system-wide, not per store | +| **Gift voucher** | `GiftVoucher` — prepaid value, `GiftVoucherType` physical or virtual | +| **Gift voucher usage history** | `GiftVoucherUsageHistory` — where the balance went | + +Both are ledgers. Never store a computed balance as a field — derive it from the history. diff --git a/skills/admin-area-changes/references/admin-areas.md b/.ai/knowledge/admin-areas.md similarity index 100% rename from skills/admin-area-changes/references/admin-areas.md rename to .ai/knowledge/admin-areas.md diff --git a/skills/best-practices/architecture.md b/.ai/knowledge/architecture.md similarity index 76% rename from skills/best-practices/architecture.md rename to .ai/knowledge/architecture.md index da12d9a13..8fda72c86 100644 --- a/skills/best-practices/architecture.md +++ b/.ai/knowledge/architecture.md @@ -1,6 +1,13 @@ # Best Practice: Architecture -Patterns from `Grand.Infrastructure`, `Grand.Business.*`, `Grand.Domain`. Complementary to `skills/reviews/architecture-review/SKILL.md`. +Patterns from `Grand.Infrastructure`, `Grand.Business.*`, `Grand.Domain`. Complementary to `.ai/skills/architecture-review.md`. + +Deeper documents split out of this file: + +- `.ai/knowledge/request-lifecycle.md` — startup, `IStartupApplication` priorities, middleware order, controller → view path. +- `.ai/knowledge/scoping.md` — store, vendor, customer group, language, and currency boundaries. +- `.ai/knowledge/caching.md` — `ICacheBase`, cache key constants, invalidation. +- `.ai/knowledge/domain-events.md` — commands vs queries vs notifications, handler rules. --- @@ -19,6 +26,7 @@ Rules: - Business layer depends on Domain and Data abstractions (`IRepository`), not on concrete Mongo types. - Controllers delegate to MediatR — never contain business logic. - Infrastructure registrations go in `IStartupApplication`, not `Program.cs`. +- Dependencies point inward. Core, business, and web projects never reference a plugin. --- @@ -95,6 +103,8 @@ public class GetSuggestedProductsQuery : IRequest> Command/query definitions belong in `Grand.Business.Core`; handlers belong in the relevant `Grand.Business.*` project. +Web-layer view-model preparation is a separate set of requests: query handlers in `Grand.Web/Features/Handlers/`, command handlers in `Grand.Web/Commands/Handler/`. + --- ## Domain Events @@ -114,6 +124,8 @@ await _mediator.EntityDeleted(entity); Event handlers implement `INotificationHandler>` (or Updated/Deleted). Place them in `Grand.Business.*/Events/Handlers/`. +Handler failure semantics, re-entrancy, and the missing-ambient-context trap are covered in `.ai/knowledge/domain-events.md`. + --- ## Anti-Patterns @@ -125,3 +137,6 @@ Event handlers implement `INotificationHandler>` (or Updated/D | Injecting `IMongoDatabase` into a business service | Inject `IRepository` | | Forgetting `_mediator.EntityUpdated` after `_repo.UpdateAsync` | Always publish after every mutation | | Singleton service with `IRepository` dependency | `IRepository` is Scoped — its consumer must also be Scoped | +| Caching in a controller or MediatR handler | Cache in the business service, around the repository call | +| Reading `IWorkContext` from a scheduled task or migration | Pass store/customer explicitly — there is no ambient context | +| A cache key that omits the store id for store-scoped data | Include every variable that changes the result | diff --git a/skills/best-practices/async.md b/.ai/knowledge/async.md similarity index 100% rename from skills/best-practices/async.md rename to .ai/knowledge/async.md diff --git a/.ai/knowledge/caching.md b/.ai/knowledge/caching.md new file mode 100644 index 000000000..5663fe228 --- /dev/null +++ b/.ai/knowledge/caching.md @@ -0,0 +1,110 @@ +# Caching + +`ICacheBase` (`src/Core/Grand.Infrastructure/Caching/ICacheBase.cs`) is the only caching abstraction. Backed by in-memory cache, or Redis when configured — the interface is the same either way, and the Redis backing is why invalidation has a `publisher` flag. + +--- + +## The interface + +```csharp +public interface ICacheBase +{ + T Get(string key, Func acquire); + T Get(string key, Func acquire, int cacheTime); + Task GetAsync(string key, Func> acquire); + Task GetAsync(string key, Func> acquire, int cacheTime); + Task SetAsync(string key, Func> acquire); + Task SetAsync(string key, Func> acquire, int cacheTime); + Task RemoveAsync(string key, bool publisher = true); + Task RemoveByPrefix(string prefix, bool publisher = true); + Task Clear(bool publisher = true); +} +``` + +`GetAsync` is read-through: the `acquire` delegate runs only on a miss. Never call the repository and then `SetAsync` separately. + +`publisher: true` (the default) broadcasts the invalidation to other instances through the Redis message bus. Pass `false` **only** when handling an invalidation message that already arrived from another instance — otherwise you create an invalidation loop. + +## Cache keys + +Keys are `static string` members on the partial `CacheKey` class, split by area: + +``` +src/Core/Grand.Infrastructure/Caching/Constants/ + CommonCacheKey.cs, ProductCacheKey.cs, CategoryCacheKey.cs, + CustomerCacheKey.cs, OrdersCacheKeys.cs, VendorCacheKey.cs, … +``` + +Two constants per cached family: + +```csharp +/// {0} : store ID (empty = all stores) +public static string TAXCATEGORIES_ALL_KEY => "Grand.taxcategory.all-{0}"; + +/// Key pattern to clear cache +public static string TAXCATEGORIES_PATTERN_KEY => "Grand.taxcategory."; +``` + +Rules: + +1. Prefix every key with `Grand.` and the entity family, lowercase, dot-separated. +2. The `*_PATTERN_KEY` is the shared prefix of every key in the family, and is what `RemoveByPrefix` takes. +3. Format parameters are documented in an XML `` block, in order. Follow that convention — it is what makes the placeholders readable at the call site. +4. **Every variable that changes the result must be in the key.** Store id, language id, customer group set, currency, vendor id, page index. A missing store id in a key is a cross-store data leak, not a performance bug. +5. Never build a key inline as a string literal at the call site. Add a constant. + +## Usage + +Read path, in the business service: + +```csharp +public virtual async Task> GetAllTaxCategories(string storeId = "") +{ + var key = string.Format(CacheKey.TAXCATEGORIES_ALL_KEY, storeId); + return await _cacheBase.GetAsync(key, async () => + { + var query = _taxCategoryRepository.Table.AsQueryable(); + // … + }); +} +``` + +Write path, in the same service: + +```csharp +await _taxCategoryRepository.InsertAsync(taxCategory); + +await _cacheBase.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY); + +//event notification +await _mediator.EntityInserted(taxCategory); +``` + +Invalidate on **every** write — insert, update, and delete. A service that caches on read but forgets to invalidate on delete serves deleted records until the entry expires. + +## Cross-family invalidation + +A write sometimes invalidates families it does not own. `TaxCategoryService.Delete` clears both its own prefix and `CacheKey.PRODUCTS_PATTERN_KEY`, because products carry a tax category. + +When adding a cached family, ask what *else* embeds this data in a cached projection, and clear those prefixes too. When adding a new relationship, revisit the invalidation of both sides. + +## Where caching belongs + +| Layer | Caches? | +|---|---| +| Controller | No | +| MediatR handler | No — a handler may compose cached services, but does not own a cache key | +| Business service | **Yes** — this is the only layer that calls `ICacheBase` | +| Repository | No | + +## Anti-patterns + +- A cache key without the store id for store-scoped data. +- A cache key without the language id for localized data. +- `RemoveByPrefix` with a hand-written string instead of the `*_PATTERN_KEY` constant. +- `Clear()` to fix a stale entry — it evicts everything for every store. +- Caching an entity graph that contains customer-specific data under a key that omits the customer. +- `publisher: false` outside an invalidation-message handler. +- Caching in a scheduled task without a key that includes the store it is processing. + +See also `.ai/knowledge/performance.md` for pagination and partial-write guidance, and `.ai/knowledge/domain-events.md` for the event that accompanies each invalidation. diff --git a/.ai/knowledge/domain-events.md b/.ai/knowledge/domain-events.md new file mode 100644 index 000000000..7462357c0 --- /dev/null +++ b/.ai/knowledge/domain-events.md @@ -0,0 +1,97 @@ +# Domain Events and Notifications + +GrandNode uses MediatR for three distinct things. Keeping them apart matters, because they have different failure semantics. + +| Kind | Interface | Handlers | Failure | +|---|---|---|---| +| Query | `IRequest` | exactly one | propagates to the caller | +| Command | `IRequest` | exactly one | propagates to the caller | +| Notification (event) | `INotification` | zero or many | a throwing handler breaks the publisher | + +--- + +## Entity events + +`src/Core/Grand.Infrastructure/Events/` defines the three lifecycle notifications: + +```csharp +public class EntityInserted : INotification where T : ParentEntity +{ + public EntityInserted(T entity) { Entity = entity; } + public T Entity { get; private set; } +} +``` + +with `EntityUpdated` and `EntityDeleted` in the same shape. + +Publish through the extensions in `Grand.Infrastructure/Extensions/EventPublisherExtensions.cs`: + +```csharp +await _mediator.EntityInserted(taxCategory); +await _mediator.EntityUpdated(product); +await _mediator.EntityDeleted(category); +``` + +Do not construct the notification and call `Publish` by hand — use the extension. + +### Where to publish + +In the business service, after the repository write and after cache invalidation: + +```csharp +await _taxCategoryRepository.InsertAsync(taxCategory); +await _cacheBase.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY); +await _mediator.EntityInserted(taxCategory); +``` + +Never publish an entity event from a controller, a handler, or a repository. + +## Subscribing + +Implement `INotificationHandler`: + +```csharp +public class ProductUpdatedHandler : INotificationHandler> +{ + public async Task Handle(EntityUpdated notification, CancellationToken cancellationToken) + { + // react + } +} +``` + +Handlers are discovered by assembly scanning, including in plugins. This is the primary extension point for plugins that must react to core writes without modifying core. + +### Handler rules + +1. **A handler must not throw.** MediatR's default publisher runs handlers sequentially; an exception aborts the remaining handlers and surfaces in the caller's write path. Catch, log, and return. +2. A handler must be fast. It runs inline in the request. Long work belongs in a scheduled task — see `.ai/skills/scheduled-task.md`. +3. A handler must not assume ambient context. Entity events fire from scheduled tasks and migrations too, where `IWorkContext` is null. Read what you need off the entity, or take it as an explicit parameter. See `.ai/knowledge/scoping.md`. +4. A handler that writes the same entity type it subscribes to will re-enter itself. Guard it or restructure. +5. A handler is not a transaction participant — MongoDB writes are already committed when it runs. There is no rollback. + +## Cache events + +`Events/CacheEvent.cs` and `Events/EntityCacheEvent.cs` carry cache invalidation between application instances. Handlers for these pass `publisher: false` to `ICacheBase` so the invalidation is not rebroadcast. See `.ai/knowledge/caching.md`. + +## Message and notification events + +Plugins extend outbound messages by handling `MessageTokensAddedEvent` rather than editing the token provider. Message templates, DotLiquid drops, and the queued-email lifecycle are covered in `.ai/skills/message-notification.md`. + +## Choosing between the three + +| You want to | Use | +|---|---| +| read data for a view | query + handler in `Features/Handlers/` | +| perform a state change with a result the caller needs | command + handler in `Commands/Handler/` | +| let unknown code react to a state change | `INotification` published by the owning service | +| let a plugin extend core behavior without a fork | `INotificationHandler` in the plugin | +| do slow or retryable work | `IScheduleTask`, triggered by a flag the handler sets | + +## Anti-patterns + +- Business logic in a notification handler that the write path actually depends on. If the write is wrong without it, it is not an event — put it in the service. +- Publishing an event before the write succeeds. +- A handler that queries back the entity it was just handed. +- Multiple handlers for the same event mutating the same entity, with ordering assumptions between them. +- Swallowing an exception in a handler with no logging. diff --git a/skills/best-practices/dotnet.md b/.ai/knowledge/dotnet.md similarity index 100% rename from skills/best-practices/dotnet.md rename to .ai/knowledge/dotnet.md diff --git a/skills/plugins/plugin-module/references/module-types.md b/.ai/knowledge/module-types.md similarity index 100% rename from skills/plugins/plugin-module/references/module-types.md rename to .ai/knowledge/module-types.md diff --git a/skills/best-practices/mongodb.md b/.ai/knowledge/mongodb.md similarity index 100% rename from skills/best-practices/mongodb.md rename to .ai/knowledge/mongodb.md diff --git a/skills/best-practices/performance.md b/.ai/knowledge/performance.md similarity index 100% rename from skills/best-practices/performance.md rename to .ai/knowledge/performance.md diff --git a/skills/plugins/plugin-module/references/plugin-types.md b/.ai/knowledge/plugin-types.md similarity index 100% rename from skills/plugins/plugin-module/references/plugin-types.md rename to .ai/knowledge/plugin-types.md diff --git a/skills/project-structure/references/repository-map.md b/.ai/knowledge/repository-map.md similarity index 100% rename from skills/project-structure/references/repository-map.md rename to .ai/knowledge/repository-map.md diff --git a/.ai/knowledge/request-lifecycle.md b/.ai/knowledge/request-lifecycle.md new file mode 100644 index 000000000..37aecd006 --- /dev/null +++ b/.ai/knowledge/request-lifecycle.md @@ -0,0 +1,126 @@ +# Request Lifecycle + +How a storefront or admin HTTP request becomes a rendered page. Read this before adding a controller action, middleware, or anything that depends on "the current customer/store". + +--- + +## Startup + +`src/Web/Grand.Web/Program.cs` is deliberately thin: + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddAppSettingsJsonFile(args); +builder.AddServiceDefaults(); +StartupBase.ConfigureServices(builder.Services, builder.Configuration); +builder.ConfigureApplicationSettings(); +builder.Services.RegisterTasks(builder.Configuration); +var app = builder.Build(); +StartupBase.ConfigureRequestPipeline(app, builder.Environment); +await app.RunAsync(); +``` + +Everything else is discovered. `StartupBase` (`src/Core/Grand.Infrastructure/StartupBase.cs`) scans assemblies — including installed plugins and modules — for `IStartupApplication` and runs them ordered by `Priority`. + +**Never add registrations to `Program.cs`.** Add an `IStartupApplication` in the project that owns the service. + +### `IStartupApplication` + +```csharp +public interface IStartupApplication +{ + void ConfigureServices(IServiceCollection services, IConfiguration configuration); + void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment); + int Priority { get; } + bool BeforeConfigure { get; } +} +``` + +- `ConfigureServices` — DI registrations. Runs for every implementation, ordered by `Priority`. +- `Configure` — middleware. Runs in two passes: all `BeforeConfigure == true` implementations first (ordered by `Priority`), then all `BeforeConfigure == false`. +- `Priority` — lower runs earlier. Observed values in `Grand.Web.Common/Startup/`: + + | Priority | Startup | Purpose | + |---|---|---| + | `-50` | `UrlRewriteStartup` | rewrites before anything else sees the path | + | `-40` | `HostFilteringStartup` | | + | `-20` | `ForwardedHeadersStartup` | must run before scheme/IP is read | + | `-10` | `ErrorHandlerStartup` | wraps everything below it | + | `0` | `StartupApplication` | core service registrations | + | `100` | `GrandCommonStartup` | static files, powered-by, common middleware | + | `500` | `AuthenticationStartup` | auth + context middleware | + | `501` | `LoggerStartup` | | + | `1000` | `GrandMvcStartup` | endpoints — last | + | `10` | plugin startups (e.g. `Theme.Modern`) | | + +Pick a plugin/module priority by copying the nearest comparable component, not by inventing a number. + +## Per-request pipeline + +Ordered by the startups above: + +1. **Host filtering / forwarded headers / URL rewrite** — the request's real host, scheme, and path are settled. +2. **Error handling** — wraps everything downstream. +3. **Static files, powered-by header** (`GrandCommonStartup`). +4. **Install redirect** — `InstallUrlMiddleware` sends traffic to the installer when the DB is not installed. +5. **Authentication** — `UseGrandAuthentication()`. +6. **`ContextMiddleware`** — the important one, see below. +7. **`CultureSettingMiddleware`** — sets `CultureInfo` from the resolved working language. +8. **Endpoint routing / MVC** (`GrandMvcStartup`, priority 1000). + +### ContextMiddleware + +`src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs` resolves the ambient context for the request and stores it on `IContextAccessor`: + +```csharp +contextAccessor.StoreContext = await storeContextSetter.InitializeStoreContext(); +contextAccessor.WorkContext = await workContextSetter.InitializeWorkContext( + contextAccessor.StoreContext.CurrentStore.Id); +``` + +Consequences: + +- **Store is resolved before customer.** Customer resolution depends on the store — see `.ai/knowledge/scoping.md`. +- `IWorkContext` is **not** populated for requests whose route matches the skip list: `/scalar/{documentName}`, `/openapi/{documentName}.json`, `install`. Code reachable from those endpoints must not assume a current customer. +- Anything that runs *before* `ContextMiddleware` (priority < 500) cannot read `IWorkContext`. +- Background work — scheduled tasks, message queue processing — has no HTTP request and therefore no ambient context. Such code must take the store/customer explicitly. See `.ai/skills/scheduled-task.md`. + +`IWorkContext` exposes: `CurrentCustomer`, `OriginalCustomerIfImpersonated`, `CurrentVendor`, `WorkingLanguage`, `WorkingCurrency`, `StoreManager`, `TaxDisplayType`. + +## Controller → response + +``` +Controller action + └─ IMediator.Send(GetSomethingQuery) ← Grand.Web/Features/Handlers/… + └─ handler + └─ business service (Grand.Business.*) + └─ ICacheBase.GetAsync(key, …) ← Grand.Infrastructure/Caching + └─ IRepository ← Grand.Data + └─ MongoDB + └─ View(model) + └─ theme view-location resolution ← IThemeView, see theme-creation skill + └─ Razor view + view components + widget zones +``` + +Rules that fall out of this: + +1. Controllers hold no business logic. They resolve input, send a MediatR request, and return a result. +2. Query handlers live in `Features/Handlers/`, command handlers in `Commands/Handler/`, mirroring the request type's folder. +3. Handlers prepare view models. Business services do not know about view models. +4. Caching sits in the business service, wrapping the repository call — not in the handler or controller. +5. Repository access happens through `IRepository`. The business layer never sees a `MongoCollection`. +6. After a write, the service publishes an entity event and clears the affected cache prefix. See `.ai/knowledge/domain-events.md` and `.ai/knowledge/caching.md`. + +## View resolution + +The active theme's `IThemeView.GetViewLocations()` is consulted before the default locations, so a theme overrides only the views it ships and everything else falls through to `Grand.Web/Views`. Widget zones are expanded by the core `Widget` view component, which asks every installed `IWidgetProvider` whether it renders in that zone. + +## Where things go + +| You are adding | Put it in | +|---|---| +| A page | controller action + MediatR query + handler + view model + view | +| A cross-cutting request concern | middleware registered from an `IStartupApplication` | +| Service registration | `IStartupApplication.ConfigureServices` in the owning project | +| Something that must run before auth | `IStartupApplication` with `BeforeConfigure = true` and a low `Priority` | +| Something with no HTTP request | `IScheduleTask`, with explicit store/customer parameters | diff --git a/.ai/knowledge/scoping.md b/.ai/knowledge/scoping.md new file mode 100644 index 000000000..f58444cf9 --- /dev/null +++ b/.ai/knowledge/scoping.md @@ -0,0 +1,96 @@ +# Scoping: Store, Vendor, Customer, Language, Currency + +GrandNode is multi-store and multi-vendor by default. Almost every leak of data across a boundary in this codebase is a missing scope filter. Read this before writing a query, a controller action, or an admin screen. + +--- + +## The five axes + +| Axis | Source | Read from | +|---|---|---| +| Store | resolved first, from host/route, by `IStoreContextSetter` | `IContextAccessor.StoreContext.CurrentStore` | +| Customer | resolved after store, by `IWorkContextSetter` | `IWorkContext.CurrentCustomer` | +| Vendor | the logged-in vendor manager, if any | `IWorkContext.CurrentVendor` | +| Language | customer preference, store default, or route | `IWorkContext.WorkingLanguage` | +| Currency | customer preference or store default | `IWorkContext.WorkingCurrency` | +| Tax display | derived from customer group and settings | `IWorkContext.TaxDisplayType` | + +Store manager context (`IWorkContext.StoreManager`) identifies the store a store-owner is administering, which is not necessarily the storefront store. + +Order matters: **store is resolved before customer**, because customer resolution depends on the store. Anything that assumes the reverse is wrong. + +## Store scoping + +Entities that can be limited to stores carry `LimitedToStores` + `Stores`. The pattern: + +```csharp +if (entity.LimitedToStores && !entity.Stores.Contains(currentStore.Id)) + return null; // or filter it out of the list +``` + +For queries, filter in the database, not after materialization: + +```csharp +query = query.Where(x => !x.LimitedToStores || x.Stores.Contains(storeId)); +``` + +Settings are store-scoped through `ISettingService` — a setting can have a global value and a per-store override. Loading a setting without a store id gives the global value, which is usually not what a storefront request wants. See `.ai/skills/settings-and-localization.md`. + +Cache keys for store-scoped data must include the store id: + +```csharp +var key = string.Format(CacheKey.TAXCATEGORIES_ALL_KEY, storeId); +``` + +Omitting the store id from a cache key makes store A serve store B's data. This is the single most common scoping bug. + +## Vendor scoping + +A vendor manager may only see and modify records their vendor owns. + +- Filter by `VendorId` in the query, never in the view. +- On write, verify the loaded entity's `VendorId` matches `IWorkContext.CurrentVendor?.Id` before saving. An id in a form post is attacker-controlled. +- Vendor area views must not expose admin-only actions. See `.ai/knowledge/template-types.md`. + +## Customer group scoping + +Entities limited to customer groups carry `LimitedToGroups` + `CustomerGroups`. Same shape as store limiting: + +```csharp +if (entity.LimitedToGroups && + !entity.CustomerGroups.Intersect(customer.Groups).Any()) + return null; +``` + +Providers (`IProvider`) carry both `LimitedToStores` and `LimitedToGroups` — a payment or shipping method can be hidden per store and per customer group. + +## Language scoping + +Localized entity properties live in a `LocalizedProperty` collection on the entity, resolved through the `GetTranslation` extension with `IWorkContext.WorkingLanguage.Id`. Do not read the raw property for display, and do not cache a localized projection without the language id in the key. + +Localization *resources* (UI strings) are separate — `ITranslationService` / `LocService` in views. See `.ai/skills/settings-and-localization.md`. + +## Currency scoping + +Prices are stored in the store's primary currency and converted for display. Do not persist a converted value. Do not compare a converted price against a stored one. + +## Where the ambient context is not available + +`IWorkContext` is populated by `ContextMiddleware`, which is skipped for `/scalar/*`, `/openapi/*.json`, and `install`, and does not exist at all for: + +- scheduled tasks +- message queue / email sending +- migrations +- plugin `Install()` / `Uninstall()` + +Code in those paths must receive store, customer, or language **explicitly as parameters**. Reaching for `IWorkContext` there yields null or a stale context. + +## Review checklist + +- [ ] Every list query filters by store, and by vendor when the caller is a vendor. +- [ ] Every write re-checks ownership against the server-side context, not the posted id. +- [ ] Every cache key for scoped data includes store id (and language id when localized). +- [ ] Settings are loaded with the correct store id. +- [ ] Localized values are read through the translation extension with the working language. +- [ ] Background code takes scope as a parameter instead of reading `IWorkContext`. +- [ ] Admin, store-area, and vendor-area versions of the same screen each apply their own scope — reusing a shared model does not reuse the filter. diff --git a/skills/best-practices/security.md b/.ai/knowledge/security.md similarity index 98% rename from skills/best-practices/security.md rename to .ai/knowledge/security.md index fab34fc3d..5fdb92e06 100644 --- a/skills/best-practices/security.md +++ b/.ai/knowledge/security.md @@ -1,6 +1,6 @@ # Best Practice: Security -Patterns from `Grand.Web`, `Grand.Module.Api`, `Grand.Business.*`. Complementary to `skills/reviews/security-review/SKILL.md`. +Patterns from `Grand.Web`, `Grand.Module.Api`, `Grand.Business.*`. Complementary to `.ai/skills/security-review.md`. --- diff --git a/skills/template-creation/references/template-types.md b/.ai/knowledge/template-types.md similarity index 100% rename from skills/template-creation/references/template-types.md rename to .ai/knowledge/template-types.md diff --git a/skills/best-practices/tests.md b/.ai/knowledge/tests.md similarity index 100% rename from skills/best-practices/tests.md rename to .ai/knowledge/tests.md diff --git a/.ai/principles.md b/.ai/principles.md new file mode 100644 index 000000000..a32b2108a --- /dev/null +++ b/.ai/principles.md @@ -0,0 +1,75 @@ +# Principles + +Why the code is shaped the way it is. These are judgment calls, not mechanical rules — when two principles conflict, the one earlier in this list wins. + +Hard prohibitions live in `.ai/constraints.md`. Mechanical conventions live in `.ai/standards/`. + +--- + +## 1. Correctness across boundaries beats everything + +GrandNode is multi-store, multi-vendor, multi-language, multi-currency. A feature that works for one store and leaks another's data is not a working feature. + +Every query, cache key, and setting read carries its scope. When in doubt about where a scope belongs, put it in the query. See `.ai/knowledge/scoping.md`. + +## 2. The domain has no dependencies + +`Grand.Domain` knows nothing about MongoDB, HTTP, Razor, or MediatR. Business logic that belongs to the domain lives with the entity; business logic about a use case lives in `Grand.Business.*`. + +Infrastructure never leaks inward. A `MongoDB.Driver` type in a business service signature, a `HttpContext` in a domain method, or a view model in a service is the same mistake in three places. + +## 3. Prefer explicit over implicit + +- Pass the store id; do not reach for ambient context in a service. +- Name the resource key; do not compose it at runtime from fragments. +- Declare the dependency in the constructor; do not resolve it from `IServiceProvider`. +- State the cache key's parameters in the constant's ``; do not leave callers guessing what `{0}` is. + +Explicit code is greppable. Implicit code is only discoverable by running it. + +## 4. Composition over inheritance + +Extend through interfaces and registration, not through base classes. A plugin adds behavior by registering a provider or an `INotificationHandler`, not by subclassing core services. + +The base classes that do exist (`BasePlugin`, `BaseEntity`, `Base*Controller`) supply mechanics, not behavior. When a new capability is needed, add an interface and register an implementation. + +## 5. Expected failures are values; unexpected failures are exceptions + +A declined payment, a failed login, an out-of-stock item, and a rejected validator are all normal outcomes. They return result objects — `PlaceOrderResult`, `CustomerLoginResults`, `ProcessPaymentResult`. + +Exceptions are for conditions no caller can sensibly handle. Using an exception for a business outcome makes the happy path unreadable and the failure path untestable. + +## 6. Extension points over forks + +Everything an integrator might want to change should be reachable without editing core: providers, notification handlers, widget zones, view-location fallback, settings, message tokens. + +When a plugin cannot do something without a core change, the right fix is usually a new extension point in core — not a bigger plugin, and not a fork. A theme that copies every view has stopped being an extension. + +## 7. Optimize for the next reader + +The next reader is someone debugging a store outage with no context. Favour: + +- one obvious path over a clever general one +- a longer name over an abbreviation +- a flat sequence over nested conditionals +- deleting dead code over keeping it commented + +Performance work is justified by a measurement, not by intuition. An unmeasured optimization that costs readability is a net loss. + +## 8. Consistency with the neighbourhood beats personal preference + +The strongest signal for how to write something is the closest existing file that does the same job. Match its structure, naming, comment density, and error handling. + +If the local pattern is genuinely wrong, fix it deliberately and separately — not as a silent side effect of another change. + +## 9. Changes are safe for existing installations + +Every installation upgrades in place. A new setting needs a default that preserves current behavior. A new permission needs a migration. A renamed system name breaks a plugin that shipped. + +The question is always: what happens to a store that already has data and is running the previous version? + +## 10. Small, reversible, and stated + +One logical change per commit. A refactor and a behavior change never travel together. If the diff cannot be explained in two sentences, it is doing more than one thing. + +Say what was validated and what was not. An unverified claim in a PR description costs more than the work it describes. diff --git a/.ai/prompts/add-migration.md b/.ai/prompts/add-migration.md new file mode 100644 index 000000000..b8b9e0c91 --- /dev/null +++ b/.ai/prompts/add-migration.md @@ -0,0 +1,44 @@ +# Prompt: Add Migration + +## Purpose +Add an upgrade migration that seeds or changes data for existing installations — settings, localization resources, permissions, admin sitemap entries, scheduled tasks, or document shape. + +## Inputs Required +- Repository root. +- What the migration must change, and why an existing installation cannot work without it. +- Target `DbVersion` (the version the change ships in). +- Whether the change is additive or rewrites existing documents. + +## Steps + +1. Read `.ai/skills/database-review.md` and `.ai/skills/mongodb-review.md`. +2. Open `src/Modules/Grand.Module.Migration/Migrations/` and read the highest existing version folder. Follow its file naming. +3. Decide the version folder. Create it only if the release version is new. +4. Create a class implementing `IMigration`: + - `Priority` — ordering inside the version. Resource and permission migrations usually run at `0`. + - `Version` — `new DbVersion(major, minor)`. + - `Identity` — a **newly generated** GUID, never copied from another migration. + - `Name` — a short human-readable description including the version. + - `UpgradeProcess(IServiceProvider)` — returns `false` on failure, and must not throw. +5. For localization resources, add the strings to `App_Data/Resources/Upgrade/en_{version}.xml` and call `serviceProvider.ImportLanguageResourcesFromXml(...)` rather than writing resources inline. +6. For permissions, admin sitemap, or scheduled tasks, follow the existing `MigrationSystemPermission`, `MigrationUpdateAdminSiteMap`, and `MigrationScheduleTasks` files in the nearest version folder. +7. Bump the DB version with a `MigrationUpgradeDbVersion_{version}` class when introducing a new version folder. +8. Verify the migration is idempotent: running it twice must not duplicate data or overwrite operator changes. + +## Mandatory Rules + +1. `Identity` must be unique across the whole repository — the migration runner uses it to record what already ran. +2. A migration must never throw; catch and return `false`. +3. A migration must be idempotent — check for existing records before inserting. +4. A migration must not delete operator-owned data unless that is explicitly the requested change. +5. Resource changes go through the upgrade XML file, not through hardcoded strings in the migration. +6. New settings must be added with defaults that preserve existing behavior for upgraded stores. +7. Do not reuse a `DbVersion` that has already shipped for a behavior change; add a new migration in the current version instead. + +## Output Format + +- **Migration**: class name, version, priority, identity GUID. +- **What it seeds**: settings, resources, permissions, sitemap, or tasks. +- **Idempotency**: exactly how a second run is made a no-op. +- **Rollback**: what an operator has to do if this migration is wrong. +- **Validation**: build and test results. diff --git a/.ai/prompts/create-plugin.md b/.ai/prompts/create-plugin.md new file mode 100644 index 000000000..886e9b043 --- /dev/null +++ b/.ai/prompts/create-plugin.md @@ -0,0 +1,59 @@ +# Prompt: Create Plugin + +## Purpose +Scaffold a new installable GrandNode plugin end to end, with the correct project shape, manifest, provider registration, and install/uninstall behavior. + +## Inputs Required +- Repository root. +- Plugin kind: payment, shipping, tax, widget, external authentication, discount rule, exchange rate, or theme. +- System name, following `{Group}.{Name}` (e.g. `Payments.Adyen`, `Widgets.Chat`). +- Friendly name shown in the admin plugin list. +- Whether the plugin needs admin configuration, storefront UI, persisted settings, or its own data collection. + +## Steps + +1. Read `.ai/knowledge/plugin-types.md` for the inventory, structure, and manifest rules. +2. Read the skill for the plugin kind: + - payment → `.ai/skills/plugin-payment.md` + - shipping → `.ai/skills/plugin-shipping.md` + - widget → `.ai/skills/plugin-widget.md` + - discount rule → `.ai/skills/plugin-discount-rules.md` + - theme → `.ai/skills/theme-creation.md` + - anything else → `.ai/skills/plugin-module.md` +3. Read `.ai/templates/plugin/` and copy the skeleton files that apply. Read `.ai/examples/` for a worked end-to-end plugin. +4. Pick the closest existing plugin in `src/Plugins/` and diff your scaffold against it. State which one you used. +5. Create the project and add it to `GrandNode.sln`. +6. Wire up in this order: + 1. `.csproj` — SDK, `Grand.Common.props` import, output path, `Private=false` references. + 2. `Manifest.cs` — `[assembly: PluginInfo(...)]` with an existing `Group` value. + 3. `{Feature}Defaults.cs` — system name, friendly-name resource key, configuration URL. + 4. `{Feature}Settings.cs` — `ISettings` implementation, when settings are persisted. + 5. `{Feature}Provider.cs` — the provider interface for the plugin kind. + 6. `{Feature}Plugin.cs` — `BasePlugin`, `Install()` / `Uninstall()`. + 7. `StartupApplication.cs` — `IStartupApplication` registrations. + 8. `Areas/Admin/` controller + `Configure.cshtml`, when configurable. + 9. `Views/`, `Components/`, `Controllers/`, `EndpointProvider.cs`, when the plugin has storefront UI. + 10. `logo.jpg`. +7. Verify the build output lands in `src/Web/Grand.Web/Plugins/{SystemName}/`. +8. Run `.ai/prompts/review-change.md` on the result. + +## Mandatory Rules + +1. `SystemName` in `Manifest.cs` must equal the value in `{Feature}Defaults` and the output folder name. +2. `Group` must be one of the existing group names in `.ai/knowledge/plugin-types.md`. +3. All GrandNode project references must be `Private="false"`. +4. Use `Microsoft.NET.Sdk.Razor` when the plugin contains `.cshtml` files, `Microsoft.NET.Sdk` otherwise. +5. `Install()` saves default settings and adds localization resources, then calls `base.Install()` last. +6. `Uninstall()` deletes settings and removes localization resources, then calls `base.Uninstall()` last. +7. Admin controllers carry `[AuthorizeAdmin]`, `[Area("Admin")]`, and the correct `[PermissionAuthorize(...)]`. +8. Add package references without versions — versions live in `Directory.Packages.props`. + +## Output Format + +- **Plugin**: system name, group, friendly name. +- **Reference plugin**: which existing plugin the scaffold follows. +- **Files created**: path + purpose. +- **Registration**: services registered and where. +- **Install/Uninstall**: settings and resource keys handled. +- **Validation**: build result and confirmed output path. +- **Remaining work**: what the author still has to fill in (credentials, API calls, views). diff --git a/.ai/prompts/create-theme.md b/.ai/prompts/create-theme.md new file mode 100644 index 000000000..fcec8aaf5 --- /dev/null +++ b/.ai/prompts/create-theme.md @@ -0,0 +1,51 @@ +# Prompt: Create Theme + +## Purpose +Create a new storefront theme plugin, or override a subset of views in an existing theme, without forking the whole view tree. + +## Inputs Required +- Repository root. +- Theme name (used as `Theme.{Name}` system name and as the view-location folder name). +- Whether the theme is a full theme or a small override on top of the default views. +- Which pages or components the theme changes. +- Whether the theme ships its own CSS/JS, and whether it needs the Vite build. + +## Steps + +1. Read `.ai/skills/theme-creation.md` — it is the authority for `IThemeView`, view-location fallback, and asset layout. +2. Read `.ai/knowledge/template-types.md` for the view conventions of the area you are overriding. +3. Read `.ai/templates/theme/` for the skeleton files. +4. Inspect `src/Plugins/Theme.Modern/` as the reference implementation. +5. Create the project: + 1. `Theme.{Name}.csproj` — `Microsoft.NET.Sdk.Razor`, `AddRazorSupportForMvc`, `StaticWebAssetsEnabled=false`, output to `..\..\Web\Grand.Web\Plugins\Theme.{Name}\`. + 2. `Manifest.cs` — `Group = "Themes"`. + 3. `{Name}ThemePlugin.cs` — `BasePlugin, IPlugin`. + 4. `{Name}ThemeView.cs` — `IThemeView` with the view-location list. + 5. `StartupApplication.cs` — `services.AddScoped()`. + 6. `Views/{Name}/_ViewImports.cshtml` and `_ViewStart.cshtml`. + 7. `Content/` with CSS, scripts, images, and `theme.jpg` preview. + 8. `logo.jpg`. +6. Copy **only** the views the theme actually changes into `Views/{Name}/`. The fallback locations resolve everything else from `Grand.Web`. +7. Confirm each copied view still binds the same `@model` and keeps the storefront data attributes described in `.ai/knowledge/template-types.md`. +8. If the theme changes bundled frontend assets, follow `.ai/skills/frontend-bundle-workflow.md` and commit the generated bundle alongside the source. +9. Build and confirm `Views/` and `Content/` land under `src/Web/Grand.Web/Plugins/Theme.{Name}/`. + +## Mandatory Rules + +1. `IThemeView.ThemeName` must match the folder name under `Views/`. +2. `GetViewLocations()` must end with the default fallbacks `"/Views/{1}/{0}.cshtml"` and `"/Views/Shared/{0}.cshtml"`, so uncopied views still resolve. +3. `AreaName` is `""` for storefront themes. +4. `ThemeInfo.PreviewImageUrl` must point at a file that exists under `~/Plugins/Theme.{Name}/Content/`. +5. Do not copy the whole `Grand.Web/Views` tree — every copied view is a file that must be maintained against upstream changes. +6. Do not change view models, route names, or controller contracts from inside a theme. +7. Preserve `data-cart-action`, product IDs, quick-view URLs, wishlist, compare, and add-to-cart attributes in copied views. +8. Keep `Content/**` and `logo.jpg` on `CopyToOutputDirectory=PreserveNewest`. + +## Output Format + +- **Theme**: system name, theme name, full theme or override. +- **View locations**: the `GetViewLocations()` list, with a note on what falls back to default. +- **Views copied**: each path + what changed in it. +- **Assets**: CSS/JS files added and whether a bundle rebuild was required. +- **Validation**: build result and confirmed output path. +- **Upstream risk**: which copied views are most likely to drift from `Grand.Web` on upgrade. diff --git a/.ai/prompts/explore-repository.md b/.ai/prompts/explore-repository.md new file mode 100644 index 000000000..370f1f74a --- /dev/null +++ b/.ai/prompts/explore-repository.md @@ -0,0 +1,42 @@ +# Prompt: Explore Repository + +## Purpose +Answer "where does X live" and "how does X work" questions about GrandNode without guessing, and without reading the whole tree. + +## Inputs Required +- Repository root. +- The question: a feature name, an entity, a URL, an admin screen, or a symptom. + +## Steps + +1. Read `.ai/knowledge/repository-map.md` first — it maps concerns to projects. +2. Narrow by question type: + + | Question | Start at | + |---|---| + | "Where is entity X stored?" | `src/Core/Grand.Domain/{Area}/` then grep for `IRepository` | + | "What happens when a customer does X?" | `.ai/knowledge/request-lifecycle.md`, then the controller in `src/Web/Grand.Web/Controllers/` | + | "Where is this admin screen?" | `src/Web/Grand.Web.Admin/Areas/Admin/Controllers/` + matching `Views/` folder | + | "Why is this value cached/stale?" | `.ai/knowledge/caching.md`, then grep the `CacheKey` constant | + | "Who reacts when X is saved?" | `.ai/knowledge/domain-events.md`, then grep `EntityUpdated` | + | "Where is this string from?" | grep the resource key in `App_Data/Resources/` | + | "Which permission guards this?" | `.ai/skills/permission-navigation.md`, then grep the `PermissionSystemName` | + | "How do I extend this?" | `.ai/skills/project-structure.md` | + +3. Follow the chain forward: controller → MediatR request → handler → business service → repository → domain entity. +4. Confirm each claim by opening the file. Do not report a path you have not read. +5. When the question is about a plugin or theme, check `src/Plugins/` before assuming the behavior lives in core. + +## Mandatory Rules + +1. Cite `path:line` for every claim. +2. Distinguish what the code does from what it appears to intend. +3. Say explicitly when a search found nothing rather than inferring the answer. +4. Do not modify files while answering an exploration question. + +## Output Format + +- **Answer**: two or three sentences, first. +- **Chain**: the call path, each step with `path:line`. +- **Related**: adjacent files worth reading next. +- **Uncertain**: anything not verified by reading the file. diff --git a/.ai/prompts/implement-feature.md b/.ai/prompts/implement-feature.md new file mode 100644 index 000000000..d81f1c302 --- /dev/null +++ b/.ai/prompts/implement-feature.md @@ -0,0 +1,51 @@ +# Prompt: Implement Feature + +## Purpose +Implement a new feature or change an existing one in GrandNode without breaking layering, scoping, or existing conventions. + +## Inputs Required +- Repository root. +- Feature goal stated in user terms (what a customer, store owner, vendor, or admin should be able to do). +- Target area: storefront, admin, store area, vendor area, API module, plugin, or background task. +- Whether the feature needs persisted settings, localization resources, permissions, or new domain entities. + +## Steps + +1. Read `AGENTS.md` and identify every skill that applies. A feature that touches UI, data, and permissions needs all three. +2. Read `.ai/knowledge/repository-map.md` to place each new file in the owning project. +3. Read `.ai/knowledge/architecture.md` and `.ai/knowledge/request-lifecycle.md` before adding a controller action, handler, or service. +4. Read `.ai/knowledge/scoping.md` when the feature exposes data that belongs to a store, vendor, customer group, language, or currency. +5. Locate the closest existing feature of the same shape and follow it. Name the file you are copying from in your output. +6. Implement in this order, stopping when a step does not apply: + 1. Domain entity or settings class in `Grand.Domain`. + 2. Repository access through `IRepository` in the business layer. + 3. Business service + interface in `Grand.Business.Core` / `Grand.Business.*`. + 4. MediatR command/query + handler for the web layer. + 5. FluentValidation validator. + 6. Controller action with the correct authorization attribute. + 7. View model, Razor view, and localization keys. + 8. Permission entry and admin sitemap entry, when the feature is admin-facing. + 9. Migration that seeds settings, resources, permissions, or sitemap entries. + 10. Unit tests. +7. Follow `.ai/standards/naming.md` for every new type, file, setting key, and localization key. +8. Build the narrowest affected project. Run the matching test project in `src/Tests/`. +9. Run `.ai/prompts/review-change.md` against your own diff before reporting. + +## Mandatory Rules + +1. Do not add business logic to controllers — delegate to MediatR or a business service. +2. Do not reference concrete MongoDB types from the business layer; use `IRepository`. +3. Do not register services in `Program.cs`; use `IStartupApplication` in the owning project. +4. Do not add a NuGet package version inline; add it to `Directory.Packages.props`. +5. Do not hardcode user-facing strings; add localization resources and a migration that imports them. +6. Do not add a new permission without a `PermissionProvider` entry and a migration. +7. Do not widen scope beyond the stated goal. + +## Output Format + +- **Goal**: one sentence restating what was built. +- **Files changed**: path + one-line reason for each. +- **Pattern followed**: the existing file(s) used as the template. +- **Migration**: what the migration seeds, or why none is needed. +- **Validation**: build and test commands run, with results. Name any command that could not be run. +- **Risk**: what is untested or assumed. diff --git a/.ai/prompts/review-change.md b/.ai/prompts/review-change.md new file mode 100644 index 000000000..be2e0a1c0 --- /dev/null +++ b/.ai/prompts/review-change.md @@ -0,0 +1,35 @@ +# Prompt: Review Change + +## Purpose +Review a pull request or diff against repository conventions before it is accepted. + +## Inputs Required +- Repository root. +- Change set or diff to review (files changed, additions, deletions). +- Stated feature goal or pull request summary. + +## Steps + +1. Read `AGENTS.md` to identify which skills apply to the change. +2. Load the relevant skills from `.ai/skills/`. +3. Load relevant knowledge from `.ai/knowledge/` as needed (architecture rules, repository map, coding patterns). +4. Load the applicable standards from `.ai/standards/` — naming and dependencies apply to almost every change; `git-and-pr.md` applies when reviewing a pull request rather than a bare diff. +5. Review the change against the mandatory rules defined in each loaded skill and standard. +6. Check the cross-cutting traps that no single skill owns: + - store id (and language id) missing from a cache key for scoped data — see `.ai/knowledge/caching.md` + - a query or write that does not apply store or vendor scope — see `.ai/knowledge/scoping.md` + - a write without cache invalidation or without its entity event + - `IWorkContext` read from a scheduled task, migration, or plugin install + - a new user-facing string without a localization resource and migration + - a package version pinned inline instead of in `Directory.Packages.props` +7. Report findings grouped by skill, listing only high-confidence issues. + +## Output Format + +For each finding: +- **File and line**: exact location. +- **Rule violated**: which mandatory rule from which skill. +- **Impact**: what goes wrong if the issue is not fixed. +- **Suggested fix**: concrete, minimal change. + +Omit style, formatting, and preference feedback unless a mandatory rule is violated. diff --git a/.ai/prompts/write-tests.md b/.ai/prompts/write-tests.md new file mode 100644 index 000000000..349f0d22b --- /dev/null +++ b/.ai/prompts/write-tests.md @@ -0,0 +1,45 @@ +# Prompt: Write Tests + +## Purpose +Add or extend unit tests for a change, using the MSTest + Moq conventions already in `src/Tests/`. + +## Inputs Required +- Repository root. +- The type or behavior under test. +- The change or bug the tests must cover. + +## Steps + +1. Read `.ai/knowledge/tests.md` for the MSTest + Moq patterns, test structure, validator testing, and controller test setup. +2. Locate the mirror test project. Test projects mirror source projects one-to-one: + + | Source | Tests | + |---|---| + | `src/Business/Grand.Business.Catalog` | `src/Tests/Grand.Business.Catalog.Tests` | + | `src/Web/Grand.Web` | `src/Tests/Grand.Web.Tests` | + | `src/Web/Grand.Web.Admin` | `src/Tests/Grand.Web.Admin.Tests` | + | `src/Web/Grand.Web.Store` | `src/Tests/Grand.Web.Store.Tests` | + | `src/Core/Grand.Infrastructure` | `src/Tests/Grand.Infrastructure.Tests` | + | `src/Modules/Grand.Module.Api` | `src/Tests/Grand.Module.Api.Tests` | + | other modules | `src/Tests/Grand.Modules.Tests` | + +3. Read the nearest existing test class in the target project and copy its structure — fixture setup, mock naming, assertion style. +4. Write tests that cover, in this order: the happy path, the boundary the change introduced, and the failure the change fixes. +5. For a bug fix, write the failing test first and confirm it fails for the stated reason before applying the fix. +6. Run only the affected test project. + +## Mandatory Rules + +1. Mock at the interface boundary (`IRepository`, business service interfaces, `IMediator`), not concrete infrastructure. +2. Do not hit a real database, network, or file system in a unit test. +3. Each test asserts one behavior, and its name says what that behavior is. +4. Do not change production code to make a test easier unless the change is an improvement on its own terms. +5. Do not weaken or delete an existing assertion to make a suite pass — investigate why it now fails. + +## Output Format + +- **Under test**: type and behavior. +- **Tests added**: name + what each one pins down. +- **Bug fix proof**: for fixes, confirmation that the test failed before and passes after. +- **Run**: the exact test command and its result. +- **Not covered**: behavior that remains untested and why. diff --git a/skills/admin-area-changes/SKILL.md b/.ai/skills/admin-area-changes.md similarity index 98% rename from skills/admin-area-changes/SKILL.md rename to .ai/skills/admin-area-changes.md index b9f34560f..79dbfb09a 100644 --- a/skills/admin-area-changes/SKILL.md +++ b/.ai/skills/admin-area-changes.md @@ -28,7 +28,7 @@ Do not use this skill as the primary review for MongoDB query safety, template m ### Mandatory Rules 1. Identify whether the change belongs to Admin, Store Owner, Vendor, AdminShared, or multiple areas. -2. Read `references/admin-areas.md` before creating or changing an admin workflow. +2. Read `.ai/knowledge/admin-areas.md` before creating or changing an admin workflow. 3. Locate matching controllers in `src/Web/Grand.Web.Admin/Controllers`, `src/Web/Grand.Web.Store/Controllers`, and `src/Web/Grand.Web.Vendor/Controllers`. 4. Locate matching views in `src/Web/Grand.Web.Admin/Areas/Admin/Views`, `src/Web/Grand.Web.Store/Areas/Store/Views`, and `src/Web/Grand.Web.Vendor/Areas/Vendor/Views`. 5. Locate shared models, validators, and mapper profiles in `src/Web/Grand.Web.AdminShared`. diff --git a/skills/reviews/architecture-review/SKILL.md b/.ai/skills/architecture-review.md similarity index 100% rename from skills/reviews/architecture-review/SKILL.md rename to .ai/skills/architecture-review.md diff --git a/skills/reviews/database-review/SKILL.md b/.ai/skills/database-review.md similarity index 100% rename from skills/reviews/database-review/SKILL.md rename to .ai/skills/database-review.md diff --git a/skills/reviews/dotnet-review/SKILL.md b/.ai/skills/dotnet-review.md similarity index 100% rename from skills/reviews/dotnet-review/SKILL.md rename to .ai/skills/dotnet-review.md diff --git a/skills/frontend-bundle-workflow/SKILL.md b/.ai/skills/frontend-bundle-workflow.md similarity index 100% rename from skills/frontend-bundle-workflow/SKILL.md rename to .ai/skills/frontend-bundle-workflow.md diff --git a/skills/message-notification/SKILL.md b/.ai/skills/message-notification.md similarity index 100% rename from skills/message-notification/SKILL.md rename to .ai/skills/message-notification.md diff --git a/skills/reviews/mongodb-review/SKILL.md b/.ai/skills/mongodb-review.md similarity index 100% rename from skills/reviews/mongodb-review/SKILL.md rename to .ai/skills/mongodb-review.md diff --git a/skills/permission-navigation/SKILL.md b/.ai/skills/permission-navigation.md similarity index 100% rename from skills/permission-navigation/SKILL.md rename to .ai/skills/permission-navigation.md diff --git a/skills/plugins/plugin-discount-rules/SKILL.md b/.ai/skills/plugin-discount-rules.md similarity index 100% rename from skills/plugins/plugin-discount-rules/SKILL.md rename to .ai/skills/plugin-discount-rules.md diff --git a/skills/plugins/plugin-module/SKILL.md b/.ai/skills/plugin-module.md similarity index 97% rename from skills/plugins/plugin-module/SKILL.md rename to .ai/skills/plugin-module.md index e9b1080df..da81d1884 100644 --- a/skills/plugins/plugin-module/SKILL.md +++ b/.ai/skills/plugin-module.md @@ -27,8 +27,8 @@ Do not use this skill as the primary review for payment correctness, tenant isol ### Mandatory Rules 1. Identify whether the work is a plugin under `src/Plugins` or a module under `src/Modules`. 2. Identify the closest existing implementation and follow its folder, namespace, project, startup, controller, view, setting, and test patterns. -3. Read `references/plugin-types.md` before creating or changing a plugin. -4. Read `references/module-types.md` before creating or changing a module. +3. Read `.ai/knowledge/plugin-types.md` before creating or changing a plugin. +4. Read `.ai/knowledge/module-types.md` before creating or changing a module. 5. Use a stable system name that matches the repository convention, such as `Payments.X`, `Shipping.X`, `Tax.X`, `Widgets.X`, `Authentication.X`, `DiscountRules.X`, `ExchangeRate.X`, or `Theme.X`. 6. Add or update `Manifest.cs` for plugins with `[assembly: PluginInfo(...)]`. 7. Add or update a plugin class that derives from `BasePlugin` when install, uninstall, or configuration behavior is required. diff --git a/skills/plugins/plugin-payment/SKILL.md b/.ai/skills/plugin-payment.md similarity index 100% rename from skills/plugins/plugin-payment/SKILL.md rename to .ai/skills/plugin-payment.md diff --git a/skills/plugins/plugin-shipping/SKILL.md b/.ai/skills/plugin-shipping.md similarity index 100% rename from skills/plugins/plugin-shipping/SKILL.md rename to .ai/skills/plugin-shipping.md diff --git a/skills/plugins/plugin-widget/SKILL.md b/.ai/skills/plugin-widget.md similarity index 100% rename from skills/plugins/plugin-widget/SKILL.md rename to .ai/skills/plugin-widget.md diff --git a/skills/project-structure/SKILL.md b/.ai/skills/project-structure.md similarity index 98% rename from skills/project-structure/SKILL.md rename to .ai/skills/project-structure.md index 7545c328e..cc478c6e8 100644 --- a/skills/project-structure/SKILL.md +++ b/.ai/skills/project-structure.md @@ -23,7 +23,7 @@ Do not use this skill as a replacement for domain-specific skills such as plugin ## Instructions ### Mandatory Rules -1. Read `references/repository-map.md` before making structural decisions. +1. Read `.ai/knowledge/repository-map.md` before making structural decisions. 2. Identify the requested change type: domain, business service, data access, web UI, admin UI, API, plugin, module, frontend asset, test, build, or deployment. 3. Locate the closest existing feature with the same entity or workflow. 4. Follow the existing folder, namespace, dependency, registration, model, mapper, validator, controller, view, and test patterns. diff --git a/skills/scheduled-task/SKILL.md b/.ai/skills/scheduled-task.md similarity index 100% rename from skills/scheduled-task/SKILL.md rename to .ai/skills/scheduled-task.md diff --git a/skills/reviews/security-review/SKILL.md b/.ai/skills/security-review.md similarity index 100% rename from skills/reviews/security-review/SKILL.md rename to .ai/skills/security-review.md diff --git a/skills/settings-and-localization/SKILL.md b/.ai/skills/settings-and-localization.md similarity index 100% rename from skills/settings-and-localization/SKILL.md rename to .ai/skills/settings-and-localization.md diff --git a/skills/template-creation/SKILL.md b/.ai/skills/template-creation.md similarity index 98% rename from skills/template-creation/SKILL.md rename to .ai/skills/template-creation.md index 9a454afbe..df846a6b2 100644 --- a/skills/template-creation/SKILL.md +++ b/.ai/skills/template-creation.md @@ -26,7 +26,7 @@ Do not use this skill as the primary review for payment correctness, security, M ### Mandatory Rules 1. Identify the template type before editing. -2. Read `references/template-types.md` before creating a new template or changing an unfamiliar template type. +2. Read `.ai/knowledge/template-types.md` before creating a new template or changing an unfamiliar template type. 3. Locate the closest existing template in the same area and follow its folder, naming, model, layout, localization, tag helper, JavaScript, and CSS conventions. 4. Keep templates in the owning project or plugin; do not place plugin-owned views directly in `Grand.Web`. 5. Use the existing view location convention for the target area. diff --git a/.ai/skills/theme-creation.md b/.ai/skills/theme-creation.md new file mode 100644 index 000000000..0031df6ea --- /dev/null +++ b/.ai/skills/theme-creation.md @@ -0,0 +1,177 @@ +# Theme Creation + +## Purpose +Create, modify, and review GrandNode storefront themes — plugins in the `Themes` group that register an `IThemeView` and override a subset of storefront Razor views. + +## When To Use +Use this skill when building a new theme, adding or removing view overrides in an existing theme, changing theme-owned CSS/JS, changing the theme preview, or reviewing why a themed page renders the default view instead of the theme's. + +## When Not To Use +Do not use this skill for changes to `Grand.Web/Views` themselves — that is `.ai/skills/template-creation.md`. Do not use it for widget plugins that inject markup into zones — that is `.ai/skills/plugin-widget.md`. Combine with `.ai/skills/plugin-module.md` for the plugin scaffolding a theme shares with every other plugin. + +## Inputs Required +- Repository root. +- Theme name — used for `Theme.{Name}` system name, `IThemeView.ThemeName`, and the `Views/{Name}/` folder. +- Which pages the theme changes, and whether it is a full theme or a small override set. +- Whether the theme ships its own CSS/JS and whether the Vite bundle is affected. +- Whether the theme supports RTL. + +## Instructions + +### Mandatory Rules + +#### Theme registration +1. Implement `IThemeView` from `Grand.Web.Common.Themes`: + + | Member | Requirement | + |---|---| + | `AreaName` | `""` for storefront themes. | + | `ThemeName` | The theme's display key. **Must match the folder name under `Views/`.** | + | `ThemeInfo` | `record ThemeInfo(string Title, string PreviewImageUrl, string PreviewText, bool SupportRtl)`. | + | `GetViewLocations()` | Ordered list of view-location format strings. | + +2. Register it as scoped in the theme's `IStartupApplication`: + ```csharp + services.AddScoped(); + ``` +3. `GetViewLocations()` must place theme locations first and end with the default fallbacks, so uncopied views still resolve: + ```csharp + return new List { + "/Views/Modern/{1}/{0}.cshtml", + "/Views/Modern/Shared/{0}.cshtml", + "/Views/{1}/{0}.cshtml", + "/Views/Shared/{0}.cshtml" + }; + ``` + `{0}` is the view name, `{1}` is the controller name. Dropping the last two entries makes every view the theme has not copied fail to resolve. +4. `ThemeInfo.PreviewImageUrl` must point at a real file, by convention `~/Plugins/Theme.{Name}/Content/theme.jpg`. +5. Set `SupportRtl` honestly — it drives which themes the admin offers for RTL stores. + +#### Plugin scaffolding +6. `Manifest.cs` with `Group = "Themes"` and `SystemName = "Theme.{Name}"`. +7. A `BasePlugin, IPlugin` class. A theme with no settings needs no more than a declaration: + ```csharp + public class ModernThemePlugin : BasePlugin, IPlugin; + ``` +8. `Install()` / `Uninstall()` are only needed when the theme persists settings or resource keys. If overridden, call `base` last. + +#### Project file +9. SDK `Microsoft.NET.Sdk.Razor`, importing `..\..\Build\Grand.Common.props`. +10. Set both properties — omitting either breaks view compilation or leaks static web assets into the host: + ```xml + true + false + ``` +11. Set the output path for **both** Debug and Release to `..\..\Web\Grand.Web\Plugins\Theme.{Name}\`. A missing Release path means the theme vanishes from release builds. +12. Reference GrandNode projects with `false`. `Grand.Web` and `Grand.Web.Common` additionally need `all` — a theme references them for compilation only. +13. Copy content with `PreserveNewest`: + ```xml + PreserveNewest + PreserveNewest + ``` + +#### Views +14. Theme views live under `Views/{ThemeName}/`, mirroring the `Grand.Web/Views` structure: `Views/{ThemeName}/{Controller}/{Action}.cshtml` and `Views/{ThemeName}/Shared/`. +15. Add `Views/{ThemeName}/_ViewImports.cshtml` — the theme does **not** inherit `Grand.Web`'s. It must include: + ```cshtml + @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + @removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.InputTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers + @addTagHelper *, Grand.Web.Common + @addTagHelper *, Grand.Web + @* … @using for Grand.Web.Models.*, Grand.Domain.*, Grand.Web.Common.* … *@ + @inject LocService Loc + ``` + The `@removeTagHelper` line is required — without it the default `InputTagHelper` renders duplicate checkboxes. +16. Add `Views/{ThemeName}/_ViewStart.cshtml`. +17. Copy **only** the views the theme actually changes. Everything else resolves through the fallback locations. Each copied view is a file that must be re-reconciled on every upgrade. +18. A copied view keeps the same `@model`, the same route names, and the same storefront data attributes — `data-cart-action`, product IDs, quick-view URLs, wishlist, compare, add-to-cart, image `alt` / `title` / `loading`. +19. Keep widget zones (`@await Component.InvokeAsync("Widget", new { widgetZone = "..." })`) present in copied views. Dropping a zone silently disables every installed widget on that page. +20. A theme must not change view models, controller actions, or route definitions. If a theme needs different data, the change belongs in `Grand.Web`, not the theme. + +#### Assets +21. Theme-owned CSS, JS, images, and vendored libraries live under the theme's `Content/` folder, referenced as `~/Plugins/Theme.{Name}/Content/...`. +22. `Content/theme.jpg` is the admin preview image; `logo.jpg` is the plugin-list logo. Both are required. +23. When the theme changes bundled frontend source, follow `.ai/skills/frontend-bundle-workflow.md` — rebuild and commit the bundle with the source. +24. Do not reference external CDNs. + +### Recommendations +1. Use `src/Plugins/Theme.Modern/` as the reference implementation for every structural question. +2. Start from an override set — copy `Shared/_Root.cshtml` or a layout plus the handful of pages that differ — before considering a full theme. +3. Keep a note in the PR of which views were copied and at which upstream revision, so upgrades can diff them. +4. Prefer CSS overrides in `Content/css/` over copying a view just to change a class. +5. Group theme CSS by area (`common/`, `header/`, `catalog/`, `product/`, `cart/`, `customer/`) as `Theme.Modern` does, rather than one large file. + +## Key Contracts + +### IThemeView +```csharp +public interface IThemeView +{ + string AreaName { get; } + string ThemeName { get; } + ThemeInfo ThemeInfo { get; } + IEnumerable GetViewLocations(); +} + +public record ThemeInfo(string Title, string PreviewImageUrl, string PreviewText, bool SupportRtl); +``` + +### Reference implementation +```csharp +public class ModernThemeView : IThemeView +{ + public string AreaName => ""; + public string ThemeName => "Modern"; + + public ThemeInfo ThemeInfo => new("Modern theme (beta)", + "~/Plugins/Theme.Modern/Content/theme.jpg", "Minimal theme (beta)", false); + + public IEnumerable GetViewLocations() + { + return new List { + "/Views/Modern/{1}/{0}.cshtml", + "/Views/Modern/Shared/{0}.cshtml", + "/Views/{1}/{0}.cshtml", + "/Views/Shared/{0}.cshtml" + }; + } +} +``` + +## File Locations + +| Concern | Path | +|---|---| +| `IThemeView` | `src/Web/Grand.Web.Common/Themes/IThemeView.cs` | +| Default theme view | `src/Web/Grand.Web.Common/Themes/DefaultThemeView.cs` | +| Theme context | `src/Web/Grand.Web.Common/Themes/ThemeContext.cs`, `ThemeContextFactory.cs` | +| Reference theme | `src/Plugins/Theme.Modern/` | +| Theme skeleton | `.ai/templates/theme/` | +| Default storefront views | `src/Web/Grand.Web/Views/` | + +## Validation Checklist +- [ ] `ThemeName` equals the `Views/` subfolder name. +- [ ] `GetViewLocations()` ends with `/Views/{1}/{0}.cshtml` and `/Views/Shared/{0}.cshtml`. +- [ ] `IThemeView` registered with `AddScoped` in the theme's `IStartupApplication`. +- [ ] `Manifest.cs` uses `Group = "Themes"` and a `SystemName` matching the output folder. +- [ ] Output path set for both Debug **and** Release. +- [ ] `AddRazorSupportForMvc=true` and `StaticWebAssetsEnabled=false`. +- [ ] `_ViewImports.cshtml` present, including the `@removeTagHelper` for `InputTagHelper`. +- [ ] `_ViewStart.cshtml` present. +- [ ] `logo.jpg` and `Content/theme.jpg` exist and are copied to output. +- [ ] Copied views keep their `@model`, widget zones, and storefront data attributes. +- [ ] No view model, route, or controller changed from inside the theme. +- [ ] Build output present under `src/Web/Grand.Web/Plugins/Theme.{Name}/`. + +## Common Failures + +| Symptom | Cause | +|---|---| +| Theme selected, but pages render default markup | `ThemeName` does not match the `Views/` folder name, or `IThemeView` not registered | +| Some pages 500 with "view not found" | Fallback locations missing from `GetViewLocations()` | +| Duplicate checkboxes on themed forms | `@removeTagHelper … InputTagHelper` missing from the theme's `_ViewImports.cshtml` | +| Tag helpers unresolved in theme views | Theme has no `_ViewImports.cshtml`; it does not inherit `Grand.Web`'s | +| Theme missing in release deployments | Output path set only for the Debug configuration | +| Widgets disappear on a themed page | Widget zone dropped from the copied view | +| Theme preview blank in admin | `ThemeInfo.PreviewImageUrl` points at a missing or uncopied file | +| Theme CSS 404s | `Content/**` not marked `CopyToOutputDirectory` | diff --git a/.ai/standards/csharp-style.md b/.ai/standards/csharp-style.md new file mode 100644 index 000000000..047f82ec0 --- /dev/null +++ b/.ai/standards/csharp-style.md @@ -0,0 +1,97 @@ +# Standard: C# Style + +Formatting rules enforced by `.editorconfig` at the repository root, plus the conventions the codebase applies consistently on top of it. Complementary to `.ai/knowledge/dotnet.md`, which covers idioms rather than layout. + +--- + +## Enforced by `.editorconfig` + +These are not preferences — they are configured for `[*.cs]`: + +| Rule | Setting | +|---|---| +| Allman braces (methods, types, control blocks, lambdas, initializers, anonymous types) | `csharp_new_line_before_open_brace` | +| `catch` and `else` on a new line | `csharp_new_line_before_catch`, `csharp_new_line_before_else` | +| Indent switch labels and case contents | `csharp_indent_switch_labels`, `csharp_indent_case_contents` | +| `System.*` usings are **not** sorted first | `dotnet_sort_system_directives_first = false` | +| No space after a cast | `csharp_space_after_cast = false` | +| Space around `:` in inheritance clauses | `csharp_space_*_colon_in_inheritance_clause = true` | +| No space between method name and `(` | `csharp_space_between_method_call_name_and_opening_parenthesis = false` | +| Single-line blocks and statements are preserved | `csharp_preserve_single_line_*` | +| Block bodies preferred over expression bodies for methods and constructors | `csharp_style_expression_bodied_methods/constructors = false` | +| `var` for built-in types and when the type is apparent | `csharp_style_var_*` | +| Language keywords over BCL type names (`string`, not `String`) | `dotnet_style_predefined_type_*` | +| No `this.` qualification | `dotnet_style_qualification_for_* = false` | + +Expression bodies are still used across the codebase for properties, single-expression interface implementations, and small members — the `false:suggestion` rules only cover methods and constructors. + +## Language level + +`src/Build/Grand.Common.props` sets: + +- `TargetFramework` = `net10.0` +- `LangVersion` = `latest` +- `ImplicitUsings` = `true` +- `System.Text` is a global using + +Do not add `using System;` and friends that implicit usings already provide. + +## File layout + +File-scoped namespaces throughout: + +```csharp +using Grand.Infrastructure.Plugins; + +namespace Theme.Modern; + +public class ModernThemeView : IThemeView +{ +} +``` + +Order inside a type: constants, fields, constructor, properties, public methods, private methods. Nested types last. + +## Constructors and dependencies + +Constructor injection only. Assign to `readonly` fields: + +```csharp +private readonly IProductService _productService; +private readonly ICacheBase _cacheBase; + +public ProductViewModelService(IProductService productService, ICacheBase cacheBase) +{ + _productService = productService; + _cacheBase = cacheBase; +} +``` + +Do not resolve from `IServiceProvider` inside a service. The exception is `IMigration.UpgradeProcess(IServiceProvider)`, where the signature requires it. + +## Async + +- `async`/`await` all the way down; no `.Result`, `.Wait()`, or `Task.Run` to bridge sync and async. +- Accept and forward `CancellationToken` where the surrounding signatures do. +- Return `Task` directly (without `async`) only when the method is a pure pass-through. +- See `.ai/knowledge/async.md` for the full rules. + +## Nullability and guards + +- Use `ArgumentNullException.ThrowIfNull` / `ThrowIfNullOrEmpty` at the top of public service methods, not hand-written `if (x == null) throw`. +- Prefer result objects over exceptions for expected business failures. +- Use pattern matching (`is null`, `is not null`, switch expressions) over `== null` chains in new code. + +## Comments + +- XML doc comments on public interface members and on non-obvious service methods; the codebase uses `/// ` widely on interfaces. +- No commented-out code. +- No `TODO` without a linked issue number. +- Match the comment density of the file you are editing. + +## What not to introduce + +- No new DI container, mapper, or validation library — the repository uses Microsoft DI, `Grand.Mapping`, and FluentValidation. AutoMapper is **not** referenced despite the AutoMapper-compatible profile API. +- No static mutable state. +- No `#region`. +- No reflection-based lookups where a DI registration or a provider interface exists. diff --git a/.ai/standards/dependencies.md b/.ai/standards/dependencies.md new file mode 100644 index 000000000..332646d82 --- /dev/null +++ b/.ai/standards/dependencies.md @@ -0,0 +1,95 @@ +# Standard: Dependencies and Build + +--- + +## Central package management + +`Directory.Packages.props` at the repository root sets `ManagePackageVersionsCentrally=true`. + +Consequences: + +- Project files reference packages **without** a version: + ```xml + + ``` +- Versions are declared once, in `Directory.Packages.props`: + ```xml + + ``` +- Adding a version attribute in a `.csproj` is a build error, not a style issue. +- Bumping a version affects every project — call it out explicitly in the PR. + +Do not add a new third-party package when the repository already has a capability for it. Check first: + +| Need | Already present | +|---|---| +| Mediator / CQRS | `MediatR` | +| Validation | `FluentValidation` | +| Object mapping | `Grand.Mapping` (AutoMapper-compatible API; AutoMapper itself is **not** referenced) | +| MongoDB | `MongoDB.Driver` | +| Caching / distributed cache | `Microsoft.Extensions.Caching.Memory`, `StackExchange.Redis` | +| Templating for messages | `DotLiquid` | +| PDF | `Scryber.Core` | +| Images | `SixLabors.ImageSharp`, `SkiaSharp` | +| Mail | `MailKit` | +| DI scanning | `Scrutor` | +| API docs | `Microsoft.AspNetCore.OpenApi`, `Scalar.AspNetCore` | + +## Shared MSBuild props + +Every project imports `..\..\Build\Grand.Common.props`, which sets: + +- `TargetFramework` = `net10.0` +- `LangVersion` = `latest` +- `ImplicitUsings` = `true` +- global using of `System.Text` +- release build with no debug symbols +- product version, currently `2.4.0`, set in the `SetVersion` target + +Do not override `TargetFramework` or `LangVersion` in an individual project. + +## Project references + +- Reference GrandNode projects with `false` in plugins and modules — the host already loads those assemblies. +- Use `all` (plugins referencing `Grand.Web` / `Grand.Web.Common`) or `runtime` (modules) following the nearest existing project of the same kind. +- Never reference a plugin from core, business, or web projects. Dependencies point inward only. + +## Output paths + +Plugins and modules must write into the host's discovery folders: + +```xml + + ..\..\Web\Grand.Web\Plugins\{SystemName}\ + $(OutputPath) + +``` + +Modules use `..\..\Web\Grand.Web\Modules\{ModuleName}\`. Both Debug and Release configurations must be set — a missing Release path means the plugin silently disappears from release builds. + +## SDK selection + +| Contents | SDK | Extra properties | +|---|---|---| +| No Razor views | `Microsoft.NET.Sdk` | — | +| Razor views | `Microsoft.NET.Sdk.Razor` | `AddRazorSupportForMvc=true`, `StaticWebAssetsEnabled=false` | + +## Static content + +```xml + + PreserveNewest + + + PreserveNewest + +``` + +`logo.jpg` is required for a plugin to render in the admin plugin list. Themes additionally need `Content/theme.jpg` for the theme preview. + +## Adding a project + +1. Create under the correct `src/` folder — see `.ai/knowledge/repository-map.md`. +2. Import `Grand.Common.props`. +3. Add to `GrandNode.sln`. +4. Add the mirror test project under `src/Tests/` when the project contains logic. diff --git a/.ai/standards/git-and-pr.md b/.ai/standards/git-and-pr.md new file mode 100644 index 000000000..e9edc395d --- /dev/null +++ b/.ai/standards/git-and-pr.md @@ -0,0 +1,64 @@ +# Standard: Git and Pull Requests + +--- + +## Branches + +- `main` — released code. +- `develop` — integration branch; feature work targets `develop` unless told otherwise. +- Feature branches: `feature/{short-description}`, fixes: `fix/{short-description}`. + +Confirm the base branch before opening a PR. Most contributions go to `develop`. + +## Commits + +- One logical change per commit. A refactor and a behavior change belong in separate commits. +- Imperative subject line, under ~72 characters, describing the effect: `Add store scope to payment restrictions`. +- Body explains *why*, not *what* — the diff already shows what. +- Do not commit `obj/`, `bin/`, `TestResults/`, `.vs/`, `.idea/`, or IDE user files. +- Generated frontend bundles **are** committed, alongside the source that produced them. + +## Pull requests + +`PULL_REQUEST_TEMPLATE.md` at the repository root is mandatory. Fill in every section: + +``` +Resolves #issueNumber +Type: **feature|bugfix** + +## Issue +Description of the issue this PR is solving, why it's happening, and how to reproduce it. + +## Solution +Summarize your solution to the problem. + +## Breaking changes +List them, or state none. + +## Testing +Numbered, reproducible steps. Assume the reader can already run GrandNode. +``` + +Rules: + +1. Link an issue. If none exists, describe the problem in the Issue section as if one did. +2. State `Type:` as exactly one of feature or bugfix. +3. "Breaking changes: none" is an assertion — verify it. Changing a view model, removing a widget zone, renaming a plugin system name, or changing a public interface is breaking. +4. Testing steps must be executable by someone who did not write the change. +5. Keep the diff scoped to the stated issue. Unrelated formatting churn makes review harder and gets PRs rejected. + +## Before opening a PR + +- [ ] Solution builds. +- [ ] The affected test project passes. +- [ ] New user-facing strings exist as localization resources with an upgrade migration. +- [ ] New settings have defaults that preserve existing behavior. +- [ ] New permissions have a `PermissionProvider` entry and a migration. +- [ ] Generated bundles are rebuilt and committed if frontend source changed. +- [ ] `.ai/prompts/review-change.md` run against the diff. + +## Working in someone else's branch + +- Never overwrite unrelated local changes. +- Never force-push a shared branch. +- Bot-authored branches (e.g. Copilot agent branches) may receive further automated pushes — branch off them rather than committing directly, unless the change is meant to land in that PR. diff --git a/.ai/standards/naming.md b/.ai/standards/naming.md new file mode 100644 index 000000000..2f92de749 --- /dev/null +++ b/.ai/standards/naming.md @@ -0,0 +1,78 @@ +# Standard: Naming + +Binding naming rules derived from the existing GrandNode tree. When a rule below conflicts with the closest existing file, follow the existing file and say so. + +--- + +## Projects + +| Kind | Pattern | Example | +|---|---|---| +| Core | `Grand.{Concern}` | `Grand.Domain`, `Grand.Data`, `Grand.Infrastructure` | +| Business | `Grand.Business.{Area}` | `Grand.Business.Catalog`, `Grand.Business.Checkout` | +| Web | `Grand.Web`, `Grand.Web.{Area}` | `Grand.Web.Admin`, `Grand.Web.Store`, `Grand.Web.Vendor` | +| Module | `Grand.Module.{Name}` | `Grand.Module.Api`, `Grand.Module.Migration` | +| Plugin | `{Group}.{Name}` | `Payments.StripeCheckout`, `Widgets.Slider`, `Theme.Modern` | +| Tests | `{SourceProject}.Tests` | `Grand.Business.Catalog.Tests` | + +Plugin group prefixes are fixed: `Payments`, `Shipping`, `Tax`, `Widgets`, `Authentication`, `DiscountRules`, `ExchangeRate`, `Theme`. + +## Types + +| Kind | Pattern | Notes | +|---|---|---| +| Domain entity | `{Noun}` | `Product`, `Order`, `Customer` — no suffix | +| Settings | `{Area}Settings` | implements `ISettings` | +| Service interface | `I{Noun}Service` | `IOrderService` | +| Service | `{Noun}Service` | one implementation per interface unless a provider set | +| Provider | `{Feature}Provider` | implements a provider interface (`IPaymentProvider`, `IWidgetProvider`, …) | +| Plugin entry point | `{Feature}Plugin` | inherits `BasePlugin` | +| Plugin constants | `{Feature}Defaults` | static class of system names, resource keys, URLs | +| Theme view | `{Name}ThemeView` | implements `IThemeView` | +| MediatR query | `Get{Thing}Query` / `Get{Thing}` | matches the handler name | +| MediatR command | `{Verb}{Thing}Command` | `InsertBlogCommentCommand` | +| MediatR handler | `{RequestName}Handler` | `GetBlogPostHandler`, `ContactUsCommandHandler` | +| Validator | `{Model}Validator` | `AbstractValidator` | +| Validator input record | `{Name}ValidatorRecord` | positional record | +| View model | `{Thing}Model` | lives in the web project, never in `Grand.Domain` | +| Migration | `Migration{WhatItDoes}` | in `Migrations/{major}.{minor}/` | +| Scheduled task | `{Name}ScheduleTask` | implements `IScheduleTask` | +| Startup | `StartupApplication` | implements `IStartupApplication` | + +## Files and folders + +- One public type per file; file name equals type name. +- Folder name equals namespace segment. +- Razor views: `{Action}.cshtml`; view component views: `Views/Shared/Components/{ComponentName}/Default.cshtml`. +- Admin tab partials: `CreateOrUpdate.Tab{Name}.cshtml` alongside `CreateOrUpdate.cshtml`. +- Migration folders are version numbers: `2.4/`, not `v2.4/` or `2.4.0/`. + +## Keys and identifiers + +| Kind | Pattern | Example | +|---|---|---| +| Plugin system name | `{Group}.{Name}` | `Payments.CashOnDelivery` | +| Setting key | `{settingsclass}.{property}` (lowercased by the setting service) | `taxsettings.pricesincludetax` | +| Localization resource — core | `{Area}.{Screen}.{Field}` | `Admin.Catalog.Products.Fields.Name` | +| Localization resource — plugin | `Plugins.{Group}.{Name}.{Field}` | `Plugins.Payments.CashOnDelivery.Fields.DescriptionText` | +| Friendly-name resource | `Plugins.{Group}.{Name}.FriendlyName` | referenced from `{Feature}Defaults.FriendlyName` | +| Permission system name | `PermissionSystemName.{Area}` | resolved through `StandardPermission` | +| Cache key | `{ENTITY}_BY_{CRITERIA}_KEY` constant | `PRODUCTS_BY_CATEGORY_KEY` | +| Cache prefix | `{ENTITY}_PATTERN_KEY` constant | used with `RemoveByPrefix` | +| Widget zone | lowercase snake, matching the view | `product_page_bottom` | +| Schedule task name | stable string, equal to the DI key | must match `ScheduleTask.ScheduleTaskName` | + +## Members + +- `PascalCase` for types, methods, properties, constants, and public fields. +- `camelCase` for parameters and locals. +- `_camelCase` for private instance fields. +- Async methods that return `Task` are named for what they do, not with a forced `Async` suffix — follow the surrounding file. +- Boolean members read as assertions: `IsEnabled`, `HasDiscount`, `SupportRtl`. + +## Anti-patterns + +- Do not abbreviate: `CustomerService`, not `CustSvc`. +- Do not put `Manager`, `Helper`, or `Util` in a new type name unless extending an existing one. +- Do not encode the layer in the type name (`ProductServiceImpl`). +- Do not reuse a plugin system name that has shipped; it is the persisted identity of the plugin. diff --git a/.ai/standards/razor-frontend.md b/.ai/standards/razor-frontend.md new file mode 100644 index 000000000..dfab9c1bd --- /dev/null +++ b/.ai/standards/razor-frontend.md @@ -0,0 +1,52 @@ +# Standard: Razor and Frontend + +Rules for `.cshtml`, storefront JavaScript, and theme assets. Complementary to `.ai/skills/template-creation.md` (procedure) and `.ai/knowledge/template-types.md` (where each template type lives). + +--- + +## Razor + +- Strongly typed models: `@model` at the top, matching the type the action returns. +- Localization through the injected `LocService`: `@Loc["Resource.Key"]`. Never hardcode user-facing text. +- Tag helpers come from `_ViewImports.cshtml`. Every view folder that needs them must have one — plugins and themes each need their own. +- Storefront `_ViewImports.cshtml` removes the default `InputTagHelper` to avoid duplicated checkboxes. Copy that `@removeTagHelper` line when creating a theme's `_ViewImports.cshtml`. +- URLs through `Url.RouteUrl(...)` with named routes where nearby views do; do not hand-build paths. +- Widget zones stay where they are: `@await Component.InvokeAsync("Widget", new { widgetZone = "..." })`. Removing a zone is a breaking change for every installed widget plugin. +- Forms use antiforgery. AJAX mutations call `addAntiForgeryToken(data)` where nearby views do. +- `Html.Raw` only for content that is trusted or already sanitized. + +### Preserve on every product/catalog view you touch + +`data-cart-action`, product IDs, quick-view URLs, wishlist and compare attributes, and image `alt` / `title` / `loading` / priority attributes. These are contracts with the storefront JavaScript, not decoration. + +## Vue in Razor + +- Database-sourced HTML rendered through `Html.Raw` inside a Vue-controlled subtree is compiled as a template. Wrap it in `v-pre` when it may contain `{{ }}`. +- Keep view-model JSON out of inline `