ARCH-001 Phase 9: Reports controller/service consolidation (Admin/Store/Vendor) - #801
Merged
Merged
Conversation
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
…gions (12/12 shared actions complete)
…rief overrides + order-report region
…ortsController, fix test's ControllerContext setup instead Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…8/8 Admin/Store-only actions complete)
…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>
…dor action-surface guard
…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>
…orts-consolidation # Conflicts: # src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type: refactor
Issue
Grand.Web.Admin,Grand.Web.Store, andGrand.Web.Vendoreach shipped their ownReportsController(~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.mdFull 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'shistory 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/vendorIdparameters directly. Forcing this through the establishedIAdminDataScope<TEntity>(built around per-instance
HasAccess(TEntity)/CanView(TEntity)checks) would have meant a fakeTEntitywith three permanently-unused members. Instead this phase introduces a new, smaller,deliberately separate
IReportDataScopeinterface. The three hosts also don't share an identicalaction 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 hostsshare;
BaseFullReportsController : BaseReportsController, +8 actions Admin/Store-only) specificallyso 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(forcesStaffStoreId)/VendorReportDataScope(forcesCurrentVendor.Id,real product-ownership
CanIncludeProductoverride) + a 3-branch fail-closedRoutedReportDataScope, registered centrally inGrand.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 atall) stays undeclared on either shared base, written directly onto Admin's own concrete controller.
Two Admin-only inline permission checks (
CountryReport'sManageCustomers, the Bestsellers-briefwidget's
ManageOrders) are expressed asvirtualno-op hooks in the shared bases, overridden onlyon the hosts that originally had them — never hoisted into uniform behavior.
ICustomerReportViewModelService-equivalent inline logic retired; the sharedservice gained a
vendorIdparameter onPrepareBestCustomerReportLineModel(real vendorfiltering) 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.
ReportsControllers reduced to thin subclasses (each restating its own host's[Area]/[Authorize*]/[AuthorizeMenu]/[PermissionAuthorize]attributes — the exact omissionthat 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).
Grand.Web.AdminShared/Views/AdminShared/Reports/(area-parameterized via the existing
ViewContext.RouteData.Values["area"]idiom), 8 widget-zonecall 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_-prefixedrename, 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 precedentfor narrower Vendor view shapes.
superpowers:subagent-driven-development; one pre-flight rulingreordered 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:
LowStockReportGET 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 andVendor'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.
Customer()screen and best-customers-by-order-total labels now render twoAdmin.*resource keys (
Admin.Common.All,Admin.Customers.Guest) instead of theVendor.*equivalentsits 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.
BestsellersReportList/ReportBestCustomersByOrderTotalListnow read a postedmodel.StoreIdfrom the client (previously hardcoded to"") — Vendor's ownvendorIdscopingstill 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
dotnet build GrandNode.sln— 0 errors. 16 warnings: 4 pre-existing baseline (unrelated to thisdiff), 12 new — 11×
CS9107(primary-constructor parameter also passed to base constructor, thesame already-accepted pattern used by every multi-dependency
Base*Controllerin this codebase)CS8604(a pre-existing, already-flagged-and-deferred nullable-annotation gap onIReportDataScope.CanIncludeProduct, not this diff's own defect).dotnet test src/Tests/Grand.Web.Admin.Tests(unfiltered) — 678 passed, 0 failed.dotnet test src/Tests/Grand.Web.Store.Tests(unfiltered) — 47 passed, 0 failed.dotnet test src/Tests/Grand.Web.Vendor.Tests(unfiltered) — 18 passed, 0 failed.dotnet test src/Tests/Grand.Mapping.Tests(unfiltered) — 234 passed, 0 failed.MongoDB, driven as
store1@store.com/store2@store.com/vendor1@vendor.comagainst real,pre-existing order/product data (no synthetic seeding needed — Reports has no entity of its own):
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.
store1@store.com'sCountryReportshowed 5 orders / $3,856.00(exactly Store1's known order total);
store2@store.com's showed 1 order / $199.00 (exactlyStore2's) — different accounts, different correct totals, proving
RoutedReportDataScoperesolves per-request and isn't silently defaulting to one concrete scope for every host.
BestsellersReportconfirmed to render with no Store/Vendor picker for either account(
ShowStoreSelector/ShowVendorSelector = falseverified live).vendor1@vendor.com'sBestsellersReportshowed its exact 2 owned products; the same account'sVendorIdwas thentemporarily switched (direct DB update, no re-login needed —
CurrentVendorresolves liveper-request) to a different real vendor with zero products, and the report correctly flipped to
"No items to display";
VendorIdwas restored immediately after. Isolates exactly one variableand proves
scope.VendorId+CanIncludeProduct(Task 3) genuinely drive both the query and therow-level filter live.
BaseFullReportsControlleractions +PopularSearchTermsReport) confirmed to return a genuine HTTP 404 on Vendor's host via directfetch — not a permission redirect, not an empty grid.
<vc:tag text in the served HTML.ManageCustomers/ManageOrderspermission-gate checks.admin@yourstore.com's actual currentpassword 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 wasreset — not worked around. Admin's routing itself was independently verified via the final
review's own attribute cross-check (see below);
AdminReportDataScope(unrestricted) istrivially covered by real, non-mocked unit tests.
store2@store.com's password was reset as a necessary side effect (irreversible for thataccount without a manual reset) to run the Store comparison;
vendor1@vendor.com'sVendorIdwas temporarily changed and confirmed restored. No other data created or left behind — Reports
needed no synthetic seed data, unlike every prior phase.
GetBestsellersBriefReportModel(backing the Bestsellers-brief dashboard widget, shared by allthree 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
CanIncludeProductrow filter added, matching the siblingBestsellersReportListaction) with new tests independently re-verified to actually fail againstthe 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 neededextern aliascross-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.
superpowers:subagent-driven-development: 16 plan tasks (one pre-flight executionreorder, 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