Skip to content

Scope CMS lookups to the store and let a store override a shared page - #773

Merged
KrzysztofPajak merged 4 commits into
developfrom
fix/cms-store-scope
Aug 10, 2026
Merged

Scope CMS lookups to the store and let a store override a shared page#773
KrzysztofPajak merged 4 commits into
developfrom
fix/cms-store-scope

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Aug 9, 2026

Copy link
Copy Markdown
Member

Type: bugfix

Issue

Two defects in the same place: which CMS record a store gets when more than one matches.

1. Five CMS reads took the store as string storeId = ""

  • IBlogService.GetAllBlogPosts, GetAllBlogPostsByTag, GetAllBlogCategories
  • INewsService.GetAllNews
  • IPageService.GetPageBySystemName

Omitting that argument is not a compile error, and it silently turns the tenant filter off — the same shape #767 removed from the category, brand and collection lists. Four callers had omitted it and nothing could report that.

Three of them were defects a user can hit:

  1. Storefront contact page. ContactController resolved "ContactUs" without a store, on both the GET and the POST path. With StoreClosed set, whether the contact page stays reachable was decided by whichever store's page matched the system name first.
  2. Store panel dashboard. StorePageViewComponent resolved "StorePortalInfo" without a store, so a store manager could be shown another store's portal page.
  3. Vendor panel dashboard. VendorPageViewComponent did the same with "VendorPortalInfo".

Reproduce (1): create two stores, give each a ContactUs page limited to itself, close the store, and open /contactus — the page consulted is not reliably the current store's.

2. The store panel's COPY button never copied anything

On /Store/Page/Edit/{id} a store manager copies a page shared by every store in order to edit it for their own store. Pressing COPY silently returned to the list.

Copy guarded on AccessToEntityByStore, which returns true only when the page is limited to stores, belongs to this store, and belongs to no other — the exact condition under which there is nothing left to copy. The button itself only renders when the page is shared (!LimitedToStores || Stores.Count > 1). The two predicates are mutually exclusive, so the entire action body was unreachable and every POST fell through to RedirectToAction("List").

Reproduce: open the store panel, edit a page not limited to any store, press COPY — nothing is created, no error is shown.

Fixing the button exposed the reason nobody had noticed: a copy would have been inert anyway. GetPageBySystemName and GetMessageTemplateByName ordered candidates by Id alone, and a copy always carries a later ObjectId than the record it was copied from, so the shared record won every lookup. A store manager who copied a page and edited it would see no change on the storefront, and a store that overrode a message template would still send mail from the shared one.

Reproduce (with the button fixed, or by inserting the second record by hand): give a store its own page under a system name that also exists as a shared page, and open the storefront in that store — the shared page renders.

Solution

Store scope on the five signatures

The defaults are gone, which made the compiler name every caller. No parameter was reordered: storeId is already first in four of the five methods and second in GetPageBySystemName, so there was no risk of positional arguments silently swapping meaning.

The three defective callers now pass _contextAccessor.StoreContext.CurrentStore.Id.

The fourth, Grand.Web.Admin/Controllers/SearchController, is correct as it stands and now says so with an explicit storeId: "". The admin panel is global by design. Writing the empty store only where it is genuinely meant is the point of removing the default — filling it in everywhere would preserve exactly the bug this removes.

The copy guard

Copy now rejects what it should have all along: a page this store cannot read (LimitedToStores && !Stores.Contains(storeId)). The existing "already store-specific, nothing to copy" check moves after it and keeps redirecting back to Edit, matching what the button offers.

Override precedence

Both resolvers now prefer the record this store owns:

.OrderByDescending(x => x.LimitedToStores && x.Stores.Contains(storeId))

Keyed on Stores.Contains(storeId) rather than LimitedToStores, because under IgnoreStoreLimitations the ACL admits everything and another store's record would otherwise win here. LINQ ordering is stable, so the existing Id order still decides ties within each group — an installation with no overrides resolves byte-identically to before.

One page, not two, on the storefront

With the copy finally resolving, a list read saw both records under one system name: the shared page and the copy carry the same SystemName and the same IncludeInFooter / IncludeInSitemap / IncludeInMenu flags, so the page rendered twice. A new PageExtensions.PreferStoreOverrides(storeId) drops the shared record wherever the store owns one under the same system name, applied at the four render paths:

  • Grand.Web/Components/Footer.cs
  • Grand.Web/Features/Handlers/Catalog/GetMenuHandler.cs
  • Grand.Web/Features/Handlers/Common/GetSitemapHandler.cs
  • Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs

It is deliberately not inside GetAllPages. Both panels must keep listing the shared page and the copy as the two separate records they are, and keying the collapse off the existing ignoreAcl flag would reintroduce the same implicit coupling this PR removes from the five signatures.

Repository reads instead of blocking ones

Separate commit, no behavior change. GetAllPages returned Task.FromResult(query.ToList()) from a cache acquire lambda. Table is an IQueryable over the MongoDB driver, so ToList is a blocking round trip dressed up as async — and blocking inside an acquire is worse than elsewhere, because the cache holds a lock for its duration. Both resolvers and GetAllPages now await _repository.ToListAsync(query), the shape #771 introduced.

Scoped to the two files this PR already touches. Roughly a hundred more Task.FromResult(query…) sites remain across src/Business and src/Core; they are a mechanical sweep of their own, not something to bury in a bugfix.

Audit of the same pattern elsewhere

OrderBy(x => x.Id) as a tiebreaker between a shared record and a store's own exists in exactly these two resolvers repository-wide. Checked and deliberately left alone:

  • ClosedStoreAttribute — an allow-list of identifiers; both slugs stay valid URLs, which is correct.
  • Admin and store panel page lists — must show both records.
  • ProductController.CopyProduct — resolves by identifier or SeName, no shared system name to collide on.
  • SearchTermService.GetSearchTermByKeyword, CustomerService.GetCustomerByEmail / GetCustomerByUsername, GetNewsLetterSubscriptionByEmailAndStoreId — hard storeId equality, no shared-record fallthrough to order against.
  • CurrencyService — a list filter, not a single-record resolver.

Breaking changes

None for existing installations.

GetPageBySystemName filters through IAclService.Authorize, which admits any entity with LimitedToStores == false. A page not limited to a store keeps resolving in every store exactly as before; only a page explicitly limited to a different store stops being returned — the leak being closed. The new ordering changes the outcome only where a store already owns a record under a system name that also exists as a shared record, which is precisely the case that was broken.

The five interface methods now require the store. Any out-of-tree caller that omitted it stops compiling, with the compiler pointing at the decision it needs to make. That is the intended effect.

PreferStoreOverrides is a new extension method; nothing existing changes signature.

Testing

  1. dotnet build ./GrandNode.sln — succeeds. (Grand.Web needs IIS Express stopped first, or the DLL locks in src/Web/Grand.Web/bin surface as MSB3021 — a lock, not a compile error.)
  2. dotnet test src/Tests/Grand.Business.Cms.Tests — 92 pass, including the two GetPageBySystemName store-scope tests, the two precedence tests, and eight for PreferStoreOverrides (collapse, system-name casing, order preservation, empty store, another store's copy, null guard).
  3. dotnet test src/Tests/Grand.Business.Messages.Tests — 35 pass, including the two GetMessageTemplateByName precedence tests.
  4. dotnet test on Grand.Web.Admin.Tests (59) and Grand.Web.Store.Tests (17) — pass. Grand.Web.Tests (9) passed on the earlier revision of this branch and needs IIS Express stopped to build again, for the reason in step 1.
  5. Run the storefront on Kestrel and open /contactus — 200.
  6. Store scope: create two stores, give store A a page with system name ContactUs limited to store A, and browse the storefront as store B with StoreClosed enabled. Store B must not be judged by store A's page.
  7. Panels: open the store panel and the vendor panel dashboards — the portal info block renders, and a StorePortalInfo page limited to another store no longer appears.
  8. COPY: in the store panel, edit a page not limited to any store and press COPY. A copy is created, limited to this store, and the editor opens on it.
  9. Precedence: edit that copy's title and open the storefront in that store — the copy's title shows, and the page appears once in the footer, the menu and /sitemap.
  10. Other stores are unaffected: browse a second store — it still gets the shared page, once.
  11. Message templates: repeat 8–10 in the store panel's message templates and send the corresponding mail — the store's own template is used.

Note for the reviewer, out of scope here

src/Web/Grand.Web.Store/Components/StorePage.cs sat in the Store project while declaring namespace Grand.Web.Vendor.Components and inheriting BaseVendorViewComponent with its [Area("Vendor")]. That is resolved on main by #774, which this branch is rebased onto.

PageService.GetAllPages still carries an orphaned { … } block with no if or else attached to it — a leftover from an earlier refactor. Purely cosmetic, left out to keep the diff scoped.

🤖 Generated with Claude Code

The five CMS reads that take a store took it as `string storeId = ""`,
and an omitted argument is not a compile error - it silently turns the
tenant filter off. Four callers had omitted it, and the compiler could
not say so.

Three of them were defects:

- The storefront contact page resolved "ContactUs" without a store, so a
  closed store could be judged by another store's page (both the GET and
  the POST path).
- The store panel dashboard resolved "StorePortalInfo" without a store,
  showing whichever page matched first rather than the one belonging to
  the store being administered.
- The vendor panel dashboard did the same with "VendorPortalInfo".

All three now pass the current store. Pages that are not limited to any
store keep resolving everywhere, so what an existing installation can
see only narrows where a page was explicitly limited to a different
store - which is the leak being closed.

The fourth caller, the admin panel's global search, is correct as it
stands and now says so with an explicit `storeId: ""`. The admin panel
is global by design; writing the empty store where it is genuinely meant
is the point of removing the default, not a formality applied
everywhere.

Two tests pin both directions of the page lookup: a page limited to
another store is not returned, and a page limited to nothing still is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 9, 2026 20:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

KrzysztofPajak and others added 3 commits August 10, 2026 17:59
The store panel's COPY button never copied anything. Its guard called
AccessToEntityByStore, which demands that the page belong to this store
and to no other - the exact condition under which there is nothing left
to copy. The button only shows when the page is shared, so the guard and
the button were mutually exclusive and the whole action was dead code
that redirected to the list.

Fixing the button exposed the reason it went unnoticed: a copy would
have been inert anyway. GetPageBySystemName and GetMessageTemplateByName
ordered candidates by identifier alone, and a copy always carries a
later ObjectId than the record it was copied from, so the shared record
won every lookup. Both now prefer the record this store owns.

Ordering keys on Stores.Contains(storeId) rather than LimitedToStores,
because under IgnoreStoreLimitations the ACL admits everything and
another store's record would otherwise win here.

With the copy finally resolving, a list read for the storefront saw both
records under one system name and rendered the page twice. The four
render paths - footer, menu, sitemap, sitemap XML - collapse that with
PreferStoreOverrides. It is deliberately not inside GetAllPages: both
panels must keep listing the shared page and the copy as the two
separate records they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetAllPages returned Task.FromResult(query.ToList()) from a cache acquire
lambda. Table is an IQueryable over the MongoDB driver, so ToList is a
blocking round trip dressed up as async, and blocking there is worse than
elsewhere because the cache holds a lock for the duration of the acquire.

Same treatment #771 gave the paged queries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KrzysztofPajak KrzysztofPajak changed the title Require the store on the blog, news and page lookups Scope CMS lookups to the store and let a store override a shared page Aug 10, 2026
@KrzysztofPajak
KrzysztofPajak merged commit 4f0e3f7 into develop Aug 10, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the fix/cms-store-scope branch August 10, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants