Skip to content

ARCH-001 Phase 17: GiftVoucher controller/service consolidation (Admin/Store) - #812

Merged
KrzysztofPajak merged 7 commits into
developfrom
arch001/phase17-giftvoucher-consolidation
Sep 3, 2026
Merged

ARCH-001 Phase 17: GiftVoucher controller/service consolidation (Admin/Store)#812
KrzysztofPajak merged 7 commits into
developfrom
arch001/phase17-giftvoucher-consolidation

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Resolves ARCH-001 Phase 17
Type: bugfix, refactor

Issue

Grand.Web.Admin and Grand.Web.Store each carried their own GiftVoucherController (202 / 214
lines, no Vendor equivalent) duplicating CRUD and access-control logic for GiftVoucher instead of
sharing it through Grand.Web.AdminShared. Same class of drag and security-gap risk as every prior
ARCH-001 phase — investigating this entity's shape also surfaced a real "list is loose, edit is
strict, and nothing in between" gap: GetGiftVoucherQueryHandler treats an empty/null StoreId as
visible from every store's List, but Store's original ownership check was a strict equality that
silently denied Edit for exactly those same global vouchers — the same shape this initiative has
repeatedly turned into a warn-vs-deny split via EditWarningCheck, just never applied here before.

Solution

Consolidated both hosts behind a shared BaseGiftVoucherController in Grand.Web.AdminShared,
driven by a new bespoke StoreGiftVoucherDataScope/RoutedGiftVoucherDataScope
(IAdminDataScope<GiftVoucher>). No Vendor work needed — Grand.Web.Vendor has no
GiftVoucherController at all.

  • GiftVoucher : BaseEntity is a plain entity with a flat StoreId string, not IStoreLinkEntity
    (same shape family as Order) — StoreGiftVoucherDataScope is bespoke, mirroring
    StoreOrderDataScope, but (unlike Order) overrides CanView to be looser than HasAccess: a
    global (empty/null StoreId) voucher is viewable everywhere, matching the query handler's own
    cross-store visibility rule.
  • BaseGiftVoucherController holds every action (List, Create, Edit, Delete,
    NotifyRecipient, GenerateCouponCode, UsageHistoryList) — full unification, no Store-only
    actions survive (unlike Page's Copy/StorePagesList/GlobalPagesList).
  • Both concrete GiftVoucherControllers are thin subclasses. Store also keeps EditWarningCheck
    (same proven idiom as Category/Blog/Page/News, adapted for GiftVoucher's flat StoreId).
  • 6 views moved to Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/..., List.cshtml unifies
    fully (a single shared grid, like News). 7 widget zones extracted into real per-host satellite
    partials — unlike every prior phase, Store's originals had zero <vc:> calls at all to
    convert (not even dead ones), so all 7 Store satellites are genuinely new additions, not
    conversions.

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

Disclosed behavior changes (intentional, not regressions)

  • Create(POST) now consistently requires PermissionActionName.Create on both hosts. Admin's
    original required .Edit on Create(POST) while its own Create(GET) and both of Store's
    Create actions already required .Create — an internal inconsistency, fixed to the consistent
    value.
  • A Store user can now open a global gift voucher's Edit screen read-only, with a warning
    banner
    , instead of being silently redirected to List. This closes a real, previously-invisible
    gap: List already showed these vouchers to every store (per GetGiftVoucherQueryHandler's
    cross-store visibility for empty-StoreId vouchers); Edit just always denied them outright.
  • List.cshtml's row links are now unconditional for both hosts. Store's original guarded the
    link behind if(StoreId) specifically because Edit would otherwise redirect away for a global
    voucher — now that Edit opens it read-only, hiding the link was solving a problem that no longer
    exists.
  • Edit(POST)'s invalid-model redisplay now repopulates AvailableStores/AvailableCurrencies
    (found and fixed mid-implementation, not part of the original design) — the original Admin
    controller's invalid-model branch never re-prepared these lists, so a failed edit silently
    degraded Admin's store dropdown into what looked like Store's single-store view. Fixed by mirroring
    Create(POST)'s already-correct re-prepare call.

Final whole-branch review

Dispatched on the most capable model per this initiative's process, independently re-deriving
tenant isolation from the merged code rather than trusting per-task review claims. Verdict:
"Ready to merge: With fixes" — 1 Critical, 2 Important, fixed in one round:

  • Critical: the new Admin thin subclass silently dropped [AuthorizeMenu] (present on the
    original BaseAdminController via inheritance, and on every other Admin thin subclass in this
    initiative) — and its own attribute test enshrined the bug with a factually incorrect comment
    claiming Admin never had it. Fixed by restoring the attribute and inverting the test to assert its
    presence.
  • Important: UsageHistoryList was gated on the loose CanView (matching Edit's intentional
    widening), but a global voucher's usage-history rows can reference orders from any store that
    redeemed it — so a Store user opening a global voucher's History tab could read another store's
    order ids, order numbers, and redeemed amounts. Fixed by narrowing that one action's gate to the
    strict HasAccess: the History tab is unavailable for a global voucher even though Edit itself
    remains viewable, which is the narrowest fix that closes the leak without adding row-level
    filtering to a rarely-used tab.
  • Important: Store's EditWarningCheck condition (giftVoucher.StoreId != Scope.DefaultStoreId)
    was also true for another store's voucher — not just a genuinely global one — which meant probing
    a nonexistent id produced no warning while probing a real other-store id did, a cross-tenant
    existence oracle. Fixed by narrowing the condition to string.IsNullOrEmpty(giftVoucher.StoreId).

Scoped re-review confirmed all 5 fixed items (the 3 above plus 2 bundled minors: an unused using
and two missing regression tests) ADDRESSED, no new breakage, independently re-run.

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, 0 warnings attributable to this phase.
  • dotnet test src/Tests/Grand.Web.Admin.Tests — full suite green (1040+ tests, incl. the
    HasAccess/CanView truth table, the EditWarningCheck truth table, the Create(POST)
    permission regression test, and the Delete invalid-ModelState regression test).
  • dotnet test src/Tests/Grand.Web.Store.Tests — full suite green (65+ tests, incl. real behavioral
    EditWarningCheck tests driven through the actual Edit(GET) action, not just reflection).
  • dotnet test src/Tests/Grand.Mapping.Tests — 234/234.

Live smoke test (run directly against a real dev DB, not sandboxed — specifically targeting the
Critical/Important fixes from the final review):

  1. Kestrel instance on a throwaway port, real MongoDB dev database.
  2. As Admin: created a global gift voucher (no store), a Store-exclusive voucher, and a
    Store2-exclusive voucher; List/Create render correctly for all three.
  3. As store1: List correctly shows the global + own-store vouchers, correctly excludes the
    other store's — confirmed row links are now live for both (the List.cshtml unconditional-link
    change).
  4. Fix verification — EditWarningCheck: opening the global voucher's Edit showed the warning
    banner; opening the store's own voucher showed none; opening the other store's voucher
    redirected to List with no warning banner (proving the existence-oracle is closed — a
    nonexistent id and another store's real id are now indistinguishable from the outside).
  5. Fix verification — UsageHistoryList: clicking the global voucher's History tab produced a
    server-side ArgumentException (confirmed in the Kestrel log, not just the UI) — the tab is
    correctly denied. Positive control: the store's own voucher's History tab returned 200 with a
    genuinely empty (never-redeemed) history, proving the tightened gate doesn't break the happy
    path.
  6. All three synthetic gift vouchers deleted afterward via direct antiforgery-tokened POSTs; DB
    confirmed restored to its original empty GiftVoucher collection.

Disclosed, out of scope: [AuthorizeMenu]'s restored enforcement (fix 1 above) could not be
observed end-to-end in this smoke test since SecurityConfig.AuthorizeAdminMenu is false by
default in this dev environment; verified structurally instead via the attribute regression test and
the final reviewer's direct comparison against every sibling Admin thin subclass in this initiative.

🤖 Generated with Claude Code

KrzysztofPajak and others added 7 commits September 3, 2026 14:29
…t/UsageHistoryList

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

- Create shared List, Create, Edit views in Grand.Web.AdminShared
- Create shared partials: CreateOrUpdate, CreateOrUpdate.TabInfo, CreateOrUpdate.TabHistory
- Create widget-zone satellite partials for Admin and Store areas
- Delete duplicate views from Grand.Web.Admin and Grand.Web.Store
- Admin uses vc:admin-widget tag helper, Store uses vc:store-widget
- Consolidates ~600 lines of identical view code into shared AdminShared views

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

BaseGiftVoucherController.Edit(POST) was not calling PrepareGiftVoucherModel
on the invalid-model branch, causing AvailableStores to come back empty.
This made the shared view''s Model.AvailableStores.Count <= 1 condition
incorrectly evaluate to true for Admin, hiding the store dropdown on
validation errors.

Applied the same pattern that Create(POST) already uses: call
PrepareGiftVoucherModel to repopulate AvailableStores before returning
the view.

Added test: EditPost_InvalidModel_CallsPrepareGiftVoucherModelToRepopulateAvailableStores
verifies that PrepareGiftVoucherModel is called and AvailableStores is populated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…toryList scope, EditWarningCheck condition

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

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

The consolidation is internally consistent (controller/view/scoping wiring aligns with existing AdminShared patterns) and is backed by targeted regression/unit tests covering the key access-scope and permission behaviors.

Pull request overview

This PR continues ARCH-001 consolidation by removing duplicated Admin/Store GiftVoucherController logic and centralizing it in Grand.Web.AdminShared, while introducing a per-host IAdminDataScope<GiftVoucher> implementation to unify tenant isolation and explicitly support “global” (empty/null StoreId) gift vouchers as viewable-but-not-editable in Store.

Changes:

  • Introduced BaseGiftVoucherController in Grand.Web.AdminShared and reduced Admin/Store controllers to thin subclasses that mainly provide host-specific attributes and Store’s EditWarningCheck.
  • Added StoreGiftVoucherDataScope + RoutedGiftVoucherDataScope and registered them in AdminShared startup to enforce consistent per-area scoping in the combined host.
  • Moved/unified GiftVoucher views into AdminShared and added per-host widget-zone “satellite” partials; added regression/unit tests covering scoping, attributes, and controller behaviors.
File summaries
File Description
src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml Adds a new localization resource for Store warning banner text.
src/Web/Grand.Web.Store/Controllers/GiftVoucherController.cs Converts Store controller into a thin subclass with Store-specific attributes + EditWarningCheck.
src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.Tabs.cshtml Store widget-zone satellite for GiftVoucher tabs.
src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.ListButtons.cshtml Store widget-zone satellite for list action buttons.
src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.InfoTop.cshtml Store widget-zone satellite for info tab top injection point.
src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.InfoBottom.cshtml Store widget-zone satellite for info tab bottom injection point.
src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.HistoryTop.cshtml Store widget-zone satellite for history tab top injection point.
src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.HistoryBottom.cshtml Store widget-zone satellite for history tab bottom injection point.
src/Web/Grand.Web.Store/Areas/Store/Views/GiftVoucher/Partials/WidgetZone.DetailsButtons.cshtml Store widget-zone satellite for create/edit page action buttons area.
src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.TabInfo.cshtml Unifies tab content and routes links/actions via current area; adds widget-zone hook points and conditional store selector.
src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml Unifies history tab and routes AJAX/read links via current area; adds widget-zone hook points.
src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Partials/CreateOrUpdate.cshtml Unifies create/update layout; conditionally posts StoreId; adds tab widget-zone hook.
src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/List.cshtml Unifies list view and routes list endpoints via current area; adds list widget-zone hook.
src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Edit.cshtml Unifies edit view and routes form post via current area; adds action-buttons widget-zone hook.
src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Create.cshtml Unifies create view and routes form post via current area; adds action-buttons widget-zone hook.
src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs Registers GiftVoucher data scopes and routed scope resolver in DI.
src/Web/Grand.Web.AdminShared/Services/StoreGiftVoucherDataScope.cs Implements Store’s bespoke GiftVoucher scoping including a looser CanView for global vouchers.
src/Web/Grand.Web.AdminShared/Services/RoutedGiftVoucherDataScope.cs Resolves correct scope by request area (Admin vs Store) and fails closed for unknown/missing areas.
src/Web/Grand.Web.AdminShared/Controllers/BaseGiftVoucherController.cs New shared controller implementing all GiftVoucher actions with unified scoping/permission logic.
src/Web/Grand.Web.Admin/Controllers/GiftVoucherController.cs Converts Admin controller into a thin subclass with Admin-specific attributes.
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.Tabs.cshtml Admin widget-zone satellite for GiftVoucher tabs.
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.ListButtons.cshtml Admin widget-zone satellite for list action buttons.
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.InfoTop.cshtml Admin widget-zone satellite for info tab top injection point.
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.InfoBottom.cshtml Admin widget-zone satellite for info tab bottom injection point.
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.HistoryTop.cshtml Admin widget-zone satellite for history tab top injection point.
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.HistoryBottom.cshtml Admin widget-zone satellite for history tab bottom injection point.
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/WidgetZone.DetailsButtons.cshtml Admin widget-zone satellite for create/edit page action buttons area.
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.TabInfo.cshtml Removes now-duplicated Admin-only tab partial (replaced by AdminShared view).
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.TabHistory.cshtml Removes now-duplicated Admin-only history partial (replaced by AdminShared view).
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Partials/CreateOrUpdate.cshtml Removes now-duplicated Admin-only wrapper partial (replaced by AdminShared view).
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/List.cshtml Removes now-duplicated Admin-only list view (replaced by AdminShared view).
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Edit.cshtml Removes now-duplicated Admin-only edit view (replaced by AdminShared view).
src/Web/Grand.Web.Admin/Areas/Admin/Views/GiftVoucher/Create.cshtml Removes now-duplicated Admin-only create view (replaced by AdminShared view).
src/Tests/Grand.Web.Store.Tests/Controllers/GiftVoucherControllerAttributeTests.cs Adds Store controller attribute checks and EditWarningCheck behavioral tests via Edit(GET).
src/Tests/Grand.Web.Admin.Tests/Controllers/StoreGiftVoucherDataScopeTests.cs Adds unit tests for Store GiftVoucher scope HasAccess/CanView and default store id.
src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedGiftVoucherDataScopeTests.cs Adds tests for correct per-area scope resolution and fail-closed behavior.
src/Tests/Grand.Web.Admin.Tests/Controllers/GiftVoucherControllerAttributeTests.cs Adds Admin thin-controller attribute coverage and regression test for Create(POST) permission.
src/Tests/Grand.Web.Admin.Tests/Controllers/BaseGiftVoucherControllerTests.cs Adds unit/regression tests for shared controller behaviors (scoping, redirects, invalid model-state flows, usage history gating).
Review details
  • Files reviewed: 38/38 changed files
  • Comments generated: 0
  • Review effort level: Lite

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

var result = CreateController().List() as ViewResult;

Assert.IsNotNull(result);
Assert.AreSame(listModel, result.Model);
var returnedModel = result?.Model as GiftVoucherModel;

Assert.IsNotNull(returnedModel);
Assert.AreEqual(2, returnedModel.AvailableStores.Count);
var returnedModel = result?.Model as GiftVoucherModel;

Assert.IsNotNull(returnedModel);
Assert.AreEqual("store-1", returnedModel.StoreId);
var result = await controller.Create(model, false) as RedirectToActionResult;

Assert.IsNotNull(result);
Assert.AreEqual("List", result.ActionName);
var result = await CreateController().Edit("missing") as RedirectToActionResult;

Assert.IsNotNull(result);
Assert.AreEqual("List", result.ActionName);
var result = await CreateController().Edit("gv-1") as RedirectToActionResult;

Assert.IsNotNull(result);
Assert.AreEqual("List", result.ActionName);
var result = await CreateController().Edit("gv-1") as ViewResult;

Assert.IsNotNull(result);
Assert.AreSame(model, result.Model);
var result = await CreateController().Edit(new GiftVoucherModel { Id = "gv-1" }, false) as RedirectToActionResult;

Assert.IsNotNull(result);
Assert.AreEqual("Edit", result.ActionName);
var result = await CreateController().Edit(model, false) as RedirectToActionResult;

Assert.IsNotNull(result);
Assert.AreEqual("List", result.ActionName);
var result = await CreateController().Delete(new GiftVoucherDeleteModel("gv-1")) as RedirectToActionResult;

Assert.IsNotNull(result);
Assert.AreEqual("Edit", result.ActionName);
@KrzysztofPajak
KrzysztofPajak merged commit 6deba9d into develop Sep 3, 2026
7 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the arch001/phase17-giftvoucher-consolidation branch September 3, 2026 20:06
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