Skip to content

ARCH-001 Phase 15: Page controller/service consolidation (Admin/Store) - #809

Merged
KrzysztofPajak merged 8 commits into
developfrom
arch001/phase15-page-consolidation
Sep 3, 2026
Merged

ARCH-001 Phase 15: Page controller/service consolidation (Admin/Store)#809
KrzysztofPajak merged 8 commits into
developfrom
arch001/phase15-page-consolidation

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Resolves ARCH-001 Phase 15
Type: refactor

Issue

Grand.Web.Admin and Grand.Web.Store each carried their own PageController (200 / 281 lines, no
Vendor equivalent) duplicating CRUD and access-control logic for Page (GrandNode's CMS page entity)
instead of sharing it through Grand.Web.AdminShared. Same class of drag and security-gap risk
documented for every prior ARCH-001 phase.

Solution

Consolidated both hosts behind one shared BasePageController in Grand.Web.AdminShared, driven by a
new RoutedPageDataScope (IAdminDataScope<Page>). No Vendor work needed — Grand.Web.Vendor has no
PageController at all.

This phase is structurally different from every prior one: Store keeps three genuinely Store-only
actions
Copy (forks a shared/global page into store-exclusive ownership — a new action class for
this initiative), StorePagesList, and GlobalPagesList (backing Store's own 2-tab list UI). These are
NOT shared into the base — Admin is already global, so "copy into my store" and the two-tab split are
Store-specific workflow decisions, not security-scope differences. List.cshtml also stays a genuine
per-host override view on both hosts — the most divergent view file this initiative has hit (Admin: a
single Kendo grid with a store-filter dropdown; Store: a 2-tab admin-tabstrip with two
independently-sourced grids).

  • BasePageController holds the shared surface: List, Create, Edit, Delete.
  • Both concrete PageControllers are thin subclasses supplying DI wiring and host attributes. Store also
    keeps EditWarningCheck (the same proven re-derived condition already used by Category/Blog) plus the
    three actions above.
  • 5 shared views moved to Grand.Web.AdminShared/Views/AdminShared/Page/...; List.cshtml kept
    per-host. 7 widget zones extracted into real per-host satellite partials (Store's <vc:admin-widget>
    calls were dead literal HTML before this change, the same bug class found and fixed in every prior
    phase — page_list_buttons' Store-side satellite is a genuinely new call, since Store's List.cshtml
    never had a dead one to convert).

Full design rationale: docs/superpowers/specs/2026-09-03-arch001-page-consolidation-design.md (spec)
and docs/superpowers/plans/2026-09-03-arch001-page-consolidation.md (plan) — both kept on disk only
(gitignored), summarized here.

Disclosed behavior changes (intentional, not regressions)

  • Preview Url now uses Request.Scheme for both hosts, instead of Admin's original hardcoded
    "http" literal — a strict, low-risk improvement (Store's original already did this).
  • The Preview button in Edit.cshtml is now gated on !string.IsNullOrEmpty(Model.SeName) for both
    hosts — Admin's original rendered it unconditionally, which would have produced a broken link for an
    empty SeName. Store's original already had this guard.
  • The preview URL now uses the page's default-language SeName, not
    GetSeName(workingLanguageId) — both originals resolved the localized slug for the current working
    language; the shared implementation resolves the default one. For a page with a localized SeName,
    the Edit-screen preview link now points at the default-language slug instead. Both slugs resolve
    correctly through the slug table, so this is cosmetic-at-worst, not a broken link — flagged because it
    wasn't an originally-planned change; the simplification avoids adding an IContextAccessor dependency
    back into the shared base.
  • Admin's Delete lost an inert ModelState.IsValid/Error(ModelState) wrapper — the action binds
    only a string id, so ModelState was always valid in practice; this unifies onto Store's simpler
    original shape with no observable behavior change.
  • Store's Edit screen button order changed (Copy/Delete swapped to Delete/Copy) so the shared view
    could match Admin's existing button ordering — purely cosmetic.

Final whole-branch review

Dispatched on the most capable model per this initiative's process. Verdict: "Ready to merge: With
fixes"
— 1 Critical, 3 Important (2 of which are the disclosed-behavior-change items above, not code
defects), fixed in one round:

  • Critical: the design spec incorrectly asserted Admin's and Store's List() GET actions were
    "confirmed identical" bare View() calls with no model. That was wrong — Admin's original called
    PreparePageListModel() to populate its store-filter dropdown; the shared implementation dropped that
    call, which would have thrown a NullReferenceException rendering /Admin/Page/List (Admin's kept
    List.cshtml dereferences Model.AvailableStores). This slipped through every per-task review because
    it lived in the seam between the controller task and the views task — exactly the kind of defect only a
    whole-branch review or a live render check can catch. Fixed: List() GET now calls
    PreparePageListModel() and passes the model for both hosts (Store's view never dereferences it, so
    this is harmless there). Live-verified after the fix (see Testing below).

Scoped re-review confirmed the fix ADDRESSED, no new breakage.

Breaking changes

None for storefront/API consumers. See "Disclosed behavior changes" above for the small set of
admin/store-panel behavior differences this consolidation surfaced or corrected.

Testing

Automated (all green):

  • dotnet build GrandNode.sln — 0 errors.
  • dotnet test src/Tests/Grand.Web.Admin.Tests — full suite green (1024+ tests).
  • dotnet test src/Tests/Grand.Web.Store.Tests — full suite green (70+ tests, incl. Copy's 3 access
    branches and StorePagesList/GlobalPagesList filtering).
  • dotnet test src/Tests/Grand.Mapping.Tests — 234/234.

Live smoke test (run directly against a real dev DB, not sandboxed — specifically targeting the page that
had the Critical regression):

  1. Kestrel instance on a throwaway port, real MongoDB dev database.
  2. As Admin: confirmed /Admin/Page/List renders correctly with the store-filter dropdown populated (the
    exact screen the Critical finding broke) — direct proof the fix works, not just that it compiles.
    Created a page limited exclusively to a second store ("Store2"); confirmed Create renders correctly
    including the Admin-only User Fields tab and CustomerGroups/Stores fields.
  3. As a different store's user ("store1"): Edit(GET) on the Store2-only page denied (redirect to
    List); crafted antiforgery-tokened Copy and Delete POSTs both denied (redirect to List), zero
    mutation
    (verified via a pre/post Admin re-read); confirmed StorePagesList returns 0 and
    GlobalPagesList returns all 15 real seeded global pages, with the Store2-only page correctly absent
    from both.
  4. Positive control as the owning store's user ("store2"): Edit succeeded with correct field visibility
    (no User Fields tab, no CustomerGroups/Stores fields); Copy — forked a real seeded global page
    (ContactUs) into a store2-exclusive copy, title/body preserved, SeName correctly de-duplicated
    (contactus-1), new copy's own ShowCopyButton correctly computed false (single-store-exclusive).
  5. Store's kept 2-tab List.cshtml rendered correctly on both tabs, backed by live StorePagesList/
    GlobalPagesList data.
  6. Zero 404s on either host — the recurring [Area]/[Authorize*] omission that has hit roughly half of
    this initiative's phases did not recur here (caught clean on first attempt).
  7. All synthetic test data (the test page + the ContactUs copy) deleted afterward; DB confirmed restored
    to its original 15-page state.

🤖 Generated with Claude Code

KrzysztofPajak and others added 5 commits September 3, 2026 06:52
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… Copy/List actions

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t.cshtml as host-override, extract widget-zone satellites
…ll in List() GET (Admin's store-filter dropdown was rendering against a null model)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 3, 2026 05:59

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.

🟢 Approval recommended

Only minor test helper cleanup was noted; the core refactor appears consistent with existing patterns and is covered by added tests.

Pull request overview

Consolidates duplicated Admin/Store CMS Page CRUD logic by introducing a shared BasePageController (in Grand.Web.AdminShared) and routing host-specific data scoping through a new RoutedPageDataScope. Store retains the intentionally Store-only actions (Copy, StorePagesList, GlobalPagesList) and the per-host List.cshtml views remain divergent as described.

Changes:

  • Introduced BasePageController + RoutedPageDataScope to centralize shared controller behavior and per-area scoping.
  • Reduced Admin/Store PageController implementations to thin subclasses; Store keeps Store-only actions and the EditWarningCheck override.
  • Moved/shared Razor views into Grand.Web.AdminShared and added per-host widget-zone partials; added/updated unit tests for the new shared surface.
File summaries
File Description
src/Web/Grand.Web.Store/Controllers/PageController.cs Refactored Store controller into thin BasePageController subclass + Store-only actions.
src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml Replaced direct widget component call with host partial indirection for list buttons.
src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/WidgetZone.Tabs.cshtml Store widget-zone satellite partial for tab injection.
src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/WidgetZone.Seo.Top.cshtml Store widget-zone satellite partial (SEO top).
src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/WidgetZone.Seo.Bottom.cshtml Store widget-zone satellite partial (SEO bottom).
src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/WidgetZone.ListButtons.cshtml Store widget-zone satellite partial (list buttons).
src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/WidgetZone.Info.Top.cshtml Store widget-zone satellite partial (info top).
src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/WidgetZone.Info.Bottom.cshtml Store widget-zone satellite partial (info bottom).
src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/WidgetZone.DetailsButtonsCreate.cshtml Store widget-zone satellite partial (create-screen buttons).
src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/WidgetZone.DetailsButtons.cshtml Store widget-zone satellite partial (edit-screen buttons).
src/Web/Grand.Web.AdminShared/Controllers/BasePageController.cs New shared controller implementing List/Create/Edit/Delete with scoped access checks.
src/Web/Grand.Web.AdminShared/Services/RoutedPageDataScope.cs New per-area IAdminDataScope<Page> router for combined-host DI.
src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs Registers global/store Page scopes and the routed scope implementation.
src/Web/Grand.Web.AdminShared/Views/AdminShared/Page/Create.cshtml Shared Create view updated to use current route area and per-host widget satellites.
src/Web/Grand.Web.AdminShared/Views/AdminShared/Page/Edit.cshtml Shared Edit view updated to be area-aware (Copy button Store-only) + widget satellites.
src/Web/Grand.Web.AdminShared/Views/AdminShared/Page/Partials/CreateOrUpdate.cshtml Shared edit form now conditionally shows Admin-only tabs/sections + widget satellites.
src/Web/Grand.Web.AdminShared/Views/AdminShared/Page/Partials/CreateOrUpdate.TabInfo.cshtml Shared “Info” tab updated for host-specific fields and widget satellites.
src/Web/Grand.Web.AdminShared/Views/AdminShared/Page/Partials/CreateOrUpdate.TabSeo.cshtml Shared “SEO” tab updated to include widget satellites.
src/Web/Grand.Web.Admin/Controllers/PageController.cs Refactored Admin controller into thin BasePageController subclass with host attributes.
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/List.cshtml Replaced direct widget component call with host partial indirection for list buttons.
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/WidgetZone.Tabs.cshtml Admin widget-zone satellite partial for tab injection.
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/WidgetZone.Seo.Top.cshtml Admin widget-zone satellite partial (SEO top).
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/WidgetZone.Seo.Bottom.cshtml Admin widget-zone satellite partial (SEO bottom).
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/WidgetZone.ListButtons.cshtml Admin widget-zone satellite partial (list buttons).
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/WidgetZone.Info.Top.cshtml Admin widget-zone satellite partial (info top).
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/WidgetZone.Info.Bottom.cshtml Admin widget-zone satellite partial (info bottom).
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/WidgetZone.DetailsButtonsCreate.cshtml Admin widget-zone satellite partial (create-screen buttons).
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/WidgetZone.DetailsButtons.cshtml Admin widget-zone satellite partial (edit-screen buttons).
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Create.cshtml Removed in favor of shared Create view in Grand.Web.AdminShared.
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Edit.cshtml Removed in favor of shared Edit view in Grand.Web.AdminShared.
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/CreateOrUpdate.cshtml Removed in favor of shared partial in Grand.Web.AdminShared.
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/CreateOrUpdate.TabInfo.cshtml Removed in favor of shared partial in Grand.Web.AdminShared.
src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/Partials/CreateOrUpdate.TabSeo.cshtml Removed in favor of shared partial in Grand.Web.AdminShared.
src/Tests/Grand.Web.Store.Tests/Controllers/PageControllerTests.cs New tests covering Store-only actions and Store host attributes.
src/Tests/Grand.Web.Admin.Tests/Controllers/BasePageControllerTests.cs New characterization tests for shared controller behavior and scoping.
src/Tests/Grand.Web.Admin.Tests/Controllers/PageControllerTests.cs New tests ensuring Admin controller is a thin subclass with required host attributes.
src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPageDataScopeTests.cs New tests covering area-based scope resolution and fail-closed behavior.
Review details

Suppressed comments (4)

src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPageDataScopeTests.cs:43

  • If Build(...) is simplified to not take unused out parameters, this call should be updated accordingly.
        var routed = Build("Admin", out _, out _);
        Assert.IsNull(routed.DefaultStoreId);

src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPageDataScopeTests.cs:58

  • If Build(...) is simplified to not take unused out parameters, this call should be updated accordingly.
        var routed = Build("Vendor", out _, out _);
        Assert.ThrowsExactly<InvalidOperationException>(() => _ = routed.DefaultStoreId);
    }

src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPageDataScopeTests.cs:65

  • If Build(...) is simplified to not take unused out parameters, this call should be updated accordingly.
        var routed = Build(null, out _, out _);
        Assert.ThrowsExactly<InvalidOperationException>(() => _ = routed.DefaultStoreId);
    }

src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPageDataScopeTests.cs:51

  • If Build(...) is simplified to not take unused out parameters, this call should be updated accordingly.
        var routed = Build("Store", out _, out _);
        Assert.AreEqual("store-1", routed.DefaultStoreId);
    }
  • Files reviewed: 37/37 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +14 to +28
private static RoutedPageDataScope Build(string area, out Mock<IAdminDataScope<Page>> globalMock, out Mock<IAdminDataScope<Page>> storeMock)
{
var httpContext = new DefaultHttpContext();
if (area != null)
httpContext.Request.RouteValues = new RouteValueDictionary { ["area"] = area };

var httpContextAccessorMock = new Mock<IHttpContextAccessor>();
httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext);

var global = new GlobalAdminDataScope<Page>();
var store = new StoreAdminDataScope<Page>(BuildContextAccessor());
globalMock = null;
storeMock = null;
return new RoutedPageDataScope(httpContextAccessorMock.Object, global, store);
}

var view = result as ViewResult;
Assert.IsNotNull(view);
Assert.IsTrue(((PageModel)view.Model).Published);

var view = result as ViewResult;
Assert.IsNotNull(view);
Assert.IsFalse(((PageModel)view.Model).Published);

var redirect = result as RedirectToActionResult;
Assert.IsNotNull(redirect);
Assert.AreEqual("List", redirect.ActionName);

var redirect = result as RedirectToActionResult;
Assert.IsNotNull(redirect);
Assert.AreEqual("List", redirect.ActionName);

var view = result as ViewResult;
Assert.IsNotNull(view);
Assert.IsTrue(((PageModel)view.Model).ShowCopyButton);
var result = await _controller.Edit("p1");

var view = result as ViewResult;
Assert.IsTrue(((PageModel)view.Model).ShowCopyButton);
var result = await _controller.Edit("p1");

var view = result as ViewResult;
Assert.IsFalse(((PageModel)view.Model).ShowCopyButton);

var redirect = result as RedirectToActionResult;
Assert.IsNotNull(redirect);
Assert.AreEqual("Edit", redirect.ActionName);

var redirect = result as RedirectToActionResult;
Assert.IsNotNull(redirect);
Assert.AreEqual("List", redirect.ActionName);
@KrzysztofPajak

Copy link
Copy Markdown
Member Author

@copilot resolve the merge conflicts in this pull request

…ge-consolidation

# Conflicts:
#	src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs

Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com>

Copilot AI commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved. I merged develop into this branch and fixed the conflict in src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs in commit c843e2527.

Comment thread src/Web/Grand.Web.Store/Controllers/PageController.cs Fixed

var redirect = result as RedirectToActionResult;
Assert.IsNotNull(redirect);
Assert.AreEqual("List", redirect.ActionName);
…er.EditWarningCheck

CodeQL flagged the inner 'page.LimitedToStores &&' in the OR's second
branch as always true, since it's only evaluated when the first
branch's !page.LimitedToStores was already false. Pre-existing legacy
idiom copied from Category/Blog's EditWarningCheck; removing the dead
re-check does not change behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FRztBQVfXimBPMFAioLabo
@KrzysztofPajak
KrzysztofPajak merged commit 43f1dd2 into develop Sep 3, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the arch001/phase15-page-consolidation branch September 3, 2026 19:09
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.

3 participants