Skip to content

ARCH-001 Phase 9: Reports controller/service consolidation (Admin/Store/Vendor) - #801

Merged
KrzysztofPajak merged 18 commits into
developfrom
arch001/phase9-reports-consolidation
Aug 29, 2026
Merged

ARCH-001 Phase 9: Reports controller/service consolidation (Admin/Store/Vendor)#801
KrzysztofPajak merged 18 commits into
developfrom
arch001/phase9-reports-consolidation

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Type: refactor

Issue

Grand.Web.Admin, Grand.Web.Store, and Grand.Web.Vendor each shipped their own ReportsController
(~660/~638/~390 lines) — the same class of duplication ARCH-001 already fixed for Product (PR #790),
Category (PR #792), Collection (PR #794), Order (PR #795), Shipment (PR #796), PaymentTransaction
(PR #797), and MerchandiseReturn (PR #799).

Full design: docs/superpowers/specs/2026-08-28-arch001-reports-consolidation-design.md
Full plan: docs/superpowers/plans/2026-08-28-arch001-reports-consolidation.md
(Both gitignored under docs/superpowers/, same as every prior phase — on disk in this branch's
history but not tracked by git.)

Materially different shape from all 7 prior phases. Reports has no entity being scoped — every
action is a read-only aggregation query against business services that already accept storeId/
vendorId parameters directly. Forcing this through the established IAdminDataScope<TEntity>
(built around per-instance HasAccess(TEntity)/CanView(TEntity) checks) would have meant a fake
TEntity with three permanently-unused members. Instead this phase introduces a new, smaller,
deliberately separate IReportDataScope interface. The three hosts also don't share an identical
action catalog: Admin and Store have 20 actions each, Vendor only 12 — not an oversight to backfill,
so the controller design uses a two-tier base (BaseReportsController, 12 actions all three hosts
share; BaseFullReportsController : BaseReportsController, +8 actions Admin/Store-only) specifically
so Vendor's concrete controller doesn't inherit routes it was never meant to have — confirmed live
(see Testing) that those 8 routes genuinely 404 on Vendor's host, not merely hidden from its menu.

Solution

  • IReportDataScope (StoreId/VendorId/ShowStoreSelector/ShowVendorSelector/
    ResourceKeyPrefix/CanIncludeProduct) + AdminReportDataScope(unrestricted)/
    StoreReportDataScope(forces StaffStoreId)/VendorReportDataScope(forces CurrentVendor.Id,
    real product-ownership CanIncludeProduct override) + a 3-branch fail-closed
    RoutedReportDataScope, registered centrally in Grand.Web.AdminShared/Startup/ StartupApplication.cs.
  • BaseReportsController (12 shared actions: Bestsellers, NeverSoldReport, CountryReport,
    LowStockReport, Customer, ReportBestCustomersByOrderTotalList) + BaseFullReportsController
    (+8 Admin/Store-only: order-period/time-chart/average/latest/incomplete reports,
    ReportBestCustomersByNumberOfOrdersList, ReportRegisteredCustomersList,
    ReportCustomerTimeChart). PopularSearchTermsReport (Admin-only, no Store/Vendor equivalent at
    all) stays undeclared on either shared base, written directly onto Admin's own concrete controller.
    Two Admin-only inline permission checks (CountryReport's ManageCustomers, the Bestsellers-brief
    widget's ManageOrders) are expressed as virtual no-op hooks in the shared bases, overridden only
    on the hosts that originally had them — never hoisted into uniform behavior.
  • Vendor's own drifted ICustomerReportViewModelService-equivalent inline logic retired; the shared
    service gained a vendorId parameter on PrepareBestCustomerReportLineModel (real vendor
    filtering) and GetReportRegisteredCustomersModel (kept for signature symmetry/forward-
    compatibility, documented as an intentional no-op today — the underlying business-layer report has
    no vendor dimension). Vendor's 14 duplicate report model files deleted outright.
  • Admin/Store/Vendor ReportsControllers reduced to thin subclasses (each restating its own host's
    [Area]/[Authorize*]/[AuthorizeMenu]/[PermissionAuthorize] attributes — the exact omission
    that broke the Order and MerchandiseReturn phases, both only caught by their own live smoke tests;
    this phase's attributes were independently cross-checked against two different already-shipped
    sibling controllers and held up clean through the live smoke test with zero routing bugs found —
    a first for this initiative's historically riskiest step).
  • Admin+Store's 12 shared views unified into Grand.Web.AdminShared/Views/AdminShared/Reports/
    (area-parameterized via the existing ViewContext.RouteData.Values["area"] idiom), 8 widget-zone
    call sites extracted into per-host satellites following PaymentTransaction's real shipped
    convention (same literal zone name on both hosts, tag-helper-only swap — not a store_-prefixed
    rename, which turned out to be an unexecuted prior phase's plan prose, not actual precedent).
    Vendor's 5 views stay host-specific (real structural differences: no store/vendor picker, no
    order-status dropdown, no tabs on Customer.cshtml), matching the Shipment phase's own precedent
    for narrower Vendor view shapes.
  • 16-task plan executed via superpowers:subagent-driven-development; one pre-flight ruling
    reordered execution (the service-unification task dispatched before two controller-region tasks
    that already called its new signature, since the plan's own task numbering didn't match a safe
    build order) — disclosed, not a plan defect requiring rewrite. Task-by-task review ledger available
    on request.

Breaking changes

None to any public method signature intentionally kept. One disclosed, deliberate behavior fix, plus
minor disclosed deltas:

  1. Admin's LowStockReport GET action loses a stray [HttpPost] attribute. Admin's original had
    [HttpPost] on this GET-only view action — almost certainly a pre-existing bug, since Store's and
    Vendor's originals never had it (both worked as plain GETs). The consolidated version drops it,
    fixing a 405 Admin's own screen previously threw. Kept as a fix, not reverted.
  2. Vendor's Customer() screen and best-customers-by-order-total labels now render two Admin.*
    resource keys (Admin.Common.All, Admin.Customers.Guest) instead of the Vendor.* equivalents
    its original inline code used, as a side effect of reusing the shared service — cosmetic if the two
    resource sets carry identical text (true for every other already-consolidated entity's equivalent
    keys, per a prior phase's own audit), not independently re-verified for Reports' specific keys.
  3. Vendor's BestsellersReportList/ReportBestCustomersByOrderTotalList now read a posted
    model.StoreId from the client (previously hardcoded to "") — Vendor's own vendorId scoping
    still applies on top regardless, so this narrows rather than widens visible data, but it is a new
    client-supplied input on an otherwise-scoped path.

Testing

  1. dotnet build GrandNode.sln — 0 errors. 16 warnings: 4 pre-existing baseline (unrelated to this
    diff), 12 new — 11× CS9107 (primary-constructor parameter also passed to base constructor, the
    same already-accepted pattern used by every multi-dependency Base*Controller in this codebase)
    • CS8604 (a pre-existing, already-flagged-and-deferred nullable-annotation gap on
      IReportDataScope.CanIncludeProduct, not this diff's own defect).
  2. dotnet test src/Tests/Grand.Web.Admin.Tests (unfiltered) — 678 passed, 0 failed.
  3. dotnet test src/Tests/Grand.Web.Store.Tests (unfiltered) — 47 passed, 0 failed.
  4. dotnet test src/Tests/Grand.Web.Vendor.Tests (unfiltered) — 18 passed, 0 failed.
  5. dotnet test src/Tests/Grand.Mapping.Tests (unfiltered) — 234 passed, 0 failed.
  6. Live smoke test run against a real Kestrel-hosted instance + this developer's own local
    MongoDB
    , driven as store1@store.com/store2@store.com/vendor1@vendor.com against real,
    pre-existing order/product data (no synthetic seeding needed — Reports has no entity of its own):
    • Zero routing bugs found — the historically riskiest step in this initiative (2 of the prior
      7 phases shipped a missing [Area]/[Authorize*] restatement, undetected by any unit test,
      only caught here) held up clean on the first live check this time.
    • Store cross-tenant proof: store1@store.com's CountryReport showed 5 orders / $3,856.00
      (exactly Store1's known order total); store2@store.com's showed 1 order / $199.00 (exactly
      Store2's) — different accounts, different correct totals, proving RoutedReportDataScope
      resolves per-request and isn't silently defaulting to one concrete scope for every host.
      BestsellersReport confirmed to render with no Store/Vendor picker for either account
      (ShowStoreSelector/ShowVendorSelector = false verified live).
    • Vendor cross-vendor proof, stronger than a two-account comparison: vendor1@vendor.com's
      BestsellersReport showed its exact 2 owned products; the same account's VendorId was then
      temporarily switched (direct DB update, no re-login needed — CurrentVendor resolves live
      per-request) to a different real vendor with zero products, and the report correctly flipped to
      "No items to display"; VendorId was restored immediately after. Isolates exactly one variable
      and proves scope.VendorId + CanIncludeProduct (Task 3) genuinely drive both the query and the
      row-level filter live.
    • All 9 Admin/Store-only-or-Admin-only routes (the 8 BaseFullReportsController actions +
      PopularSearchTermsReport) confirmed to return a genuine HTTP 404 on Vendor's host via direct
      fetch — not a permission redirect, not an empty grid.
    • Store's widget-zone fix confirmed rendering with zero raw <vc: tag text in the served HTML.
    • Not live-tested, disclosed rather than silently skipped: Admin's own screens and the
      ManageCustomers/ManageOrders permission-gate checks. admin@yourstore.com's actual current
      password is unknown (neither of the two credentials tried worked), and this session's own
      safety tooling explicitly blocked an attempt to reset it the same way store2@store.com's was
      reset — not worked around. Admin's routing itself was independently verified via the final
      review's own attribute cross-check (see below); AdminReportDataScope (unrestricted) is
      trivially covered by real, non-mocked unit tests.
    • store2@store.com's password was reset as a necessary side effect (irreversible for that
      account without a manual reset) to run the Store comparison; vendor1@vendor.com's VendorId
      was temporarily changed and confirmed restored. No other data created or left behind — Reports
      needed no synthetic seed data, unlike every prior phase.
  7. Final whole-branch review (opus) found one real Critical issue, fixed in one round:
    GetBestsellersBriefReportModel (backing the Bestsellers-brief dashboard widget, shared by all
    three hosts) had been lifted from Admin's own unscoped original and silently dropped Store's and
    Vendor's scoping — every store owner and vendor using that specific widget was seeing global,
    unscoped bestseller data. The existing tests for this method stubbed the unscoped call, so they
    verified the bug rather than catching it. Fixed (scope threaded through, matching every sibling
    action in the same file; Vendor's CanIncludeProduct row filter added, matching the sibling
    BestsellersReportList action) with new tests independently re-verified to actually fail against
    the pre-fix code, not just pass post-fix. Also fixed in the same round: a dead, misleadingly-named
    service parameter documented as an intentional no-op rather than silently discarded; two
    misplaced routing-attribute test files relocated out of Grand.Web.Admin.Tests (which needed
    extern alias cross-host references just to host them) into their own hosts' test projects,
    matching established precedent; one self-contradictory code comment corrected. Scoped re-review
    confirmed all findings addressed, no new Critical/Important breakage — three trivial Minor
    findings in the fix diff itself, all disclosed and non-blocking.
  8. Executed via superpowers:subagent-driven-development: 16 plan tasks (one pre-flight execution
    reorder, two mid-plan fix rounds — a production null-guard mistakenly added to accommodate an
    incomplete test fixture instead of fixing the fixture, and a test that conflated two causally
    distinct "action absent from Vendor" reasons into one assertion) + the live smoke test + the final
    Critical-severity fix round above, all independently re-verified clean. Task-by-task review ledger
    available on request.

🤖 Generated with Claude Code

KrzysztofPajak and others added 16 commits August 28, 2026 15:21
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ice, retire Vendor's 14 duplicate report models
…ortsController, fix test's ControllerContext setup instead

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

Also fixes Vendor's Views/Reports/_ViewImports.cshtml and Customer.cshtml, which still
referenced the 14 report models Task 11 deleted from Grand.Web.Vendor.Models.Report -
required for Grand.Web.Vendor to compile again (brief Step 7).
…Shared, fix Store's dead admin-widget tag, rebind Vendor's Customer.cshtml onto the shared nested model

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ullReportsController-vs-PopularSearchTermsReport tests, use direct typeof() for Authorize attributes

Task review finding: the guard conflated 8 causally-distinct BaseFullReportsController actions
(absent from Vendor because it doesn't inherit that base) with PopularSearchTermsReport (absent for
a different reason - never declared on any shared base, Admin-only not Admin/Store-shared) into one
undifferentiated assertion. Split per Task 12's own precedent for testing PopularSearchTermsReport's
placement. Also replaced string-based Assembly.Load/GetType reflection for AuthorizeStore/Vendor
attributes with direct typeof() references, matching the rest of this test suite's style.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rtModel, document dead vendorId parameter, relocate host routing tests to their own projects

Final whole-branch review findings:
- C1/C2 (Critical): GetBestsellersBriefReportModel was lifted from Admin's unscoped original and
  silently dropped Store's storeId / Vendor's vendorId, leaking global bestseller data to both
  hosts on the BestsellersBriefReportByQuantityList/ByAmountList widgets - also missing Vendor's
  CanIncludeProduct row-level filter. Fixed and added scope-threading + CanIncludeProduct tests
  that actually verify the fix instead of the prior tests, which stubbed the unscoped call and
  passed either way.
- I1 (Important): documented GetReportRegisteredCustomersModel's vendorId parameter as an
  intentional no-op today (ICustomerReportService.GetRegisteredCustomersReport has no vendor
  dimension), kept for signature symmetry and forward-compatibility rather than removed.
- I2 (Important): moved Store's/Vendor's routing-attribute tests out of Grand.Web.Admin.Tests
  (which needed extern-alias cross-host ProjectReferences just to host them) into their own
  Grand.Web.Store.Tests/Grand.Web.Vendor.Tests projects, matching established precedent.
- M3 (Minor): fixed Vendor's concrete controller's self-contradictory header comment (claimed an
  8-action surface derived by subtracting from 12; Vendor's actual surface is all 12 shared
  actions, zero of the 8 Full-tier ones).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 28, 2026 20: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.

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

…orts-consolidation

# Conflicts:
#	src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs
Comment thread src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs Fixed
Comment thread src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs Fixed
Comment thread src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs Fixed
Comment thread src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs Fixed
Comment thread src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs Fixed
…against CSRF false positive

CodeQL flagged POST actions on BaseFullReportsController (e.g. ReportOrderPeriodList,
ReportOrderTimeChart) as missing CSRF validation. Same false-positive class already
hardened against on BaseProductController (ARCH-001 Product phase): these abstract
base controllers are never directly routable, and Admin/Store/Vendor's concrete
ReportsController subclasses already restate [AutoValidateAntiforgeryToken] at the
class level (Task 12) - static analysis just can't follow the attribute across the
base/derived, cross-project boundary. Runtime behavior is unchanged; this closes the
gap so the same fragility (every future host subclass having to remember to restate
it) can't silently regress.

Verified: dotnet build (0 errors, same 16 pre-existing warnings), Reports-filtered
tests green across Admin (33), Store (5), Vendor (6) test projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@KrzysztofPajak
KrzysztofPajak merged commit b9f182c into develop Aug 29, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the arch001/phase9-reports-consolidation branch August 29, 2026 03:40
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