Skip to content

Split CustomerViewModelService and the customer edit tabs into one service and controller per tab - #976

Merged
KrzysztofPajak merged 2 commits into
developfrom
refactor/split-customer-viewmodel-service
Oct 23, 2026
Merged

KrzysztofPajak merged 2 commits into
developfrom
refactor/split-customer-viewmodel-service

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Part of the architecture backlog (item S5: god classes, CustomerViewModelService)
Type: feature

Issue

Grand.Web.AdminShared/Services/CustomerViewModelService.cs was 1432 lines behind a 34-member ICustomerViewModelService, with 28 constructor dependencies, and 11 more services resolved per call from HttpContext.RequestServices. Every customer tab went through the whole service.

Solution

  • One ICustomer<Tab>ViewModelService / Customer<Tab>ViewModelService per customer edit tab, registered as scoped next to ICustomerViewModelService, the same lifetime as before:

    Service Members Dependencies
    CustomerAddress 5 7
    CustomerLoyaltyPoints 2 3
    CustomerShoppingCart 3 10
    CustomerProduct (product prices, personalized products, add-product popup) 9 6
    CustomerOutOfStockSubscription 1 6
    CustomerNote 3 6
    CustomerContactForm 1 3
  • Members moved unchanged, except that the services they used to resolve from HttpContext.RequestServices (ITaxService, IPricingService, IPriceFormatter, IProductAttributeFormatter, IShoppingCartService, IContactUsService, IOutOfStockSubscriptionService, IMessageProviderService, IDownloadService) are now constructor dependencies. They are the same scoped instances for the request.

  • CustomerViewModelService keeps the customer list, the form (prepare, insert, update, delete), DeleteSelected and SendEmail: 897 lines, 8 members, 19 dependencies. Its remaining RequestServices lookups (VAT, e-mail, external authentication) are left as they were.

  • The customer edit tabs are split out of BaseCustomerController as well, one controller per tab, the same way the product tabs were (Split the product edit tabs out of BaseProductController #974):

    Controller (AdminShared base + Admin and Store subclass) Actions
    CustomerAddress AddressesSelect, AddressDelete, AddressCreate, AddressEdit
    CustomerLoyaltyPoints LoyaltyPointsHistorySelect, LoyaltyPointsHistoryAdd
    CustomerShoppingCart GetCartList, UpdateCart, DeleteCart
    CustomerProduct ProductsPrice, PersonalizedProducts, ProductAddPopup, ProductAddPopupList, Update/DeleteProductPrice, Update/DeletePersonalizedProduct
    CustomerOutOfStockSubscription OutOfStockSubscriptionList
    CustomerOrder OrderList, OrderDetails
    CustomerReview ReviewList, ReviewDelete
    CustomerNote (Admin only) CustomerNotesSelect, CustomerNoteAdd, CustomerNoteDelete
    CustomerContactForm (Admin only) ContactFormList

    They share BaseCustomerScopedController, which holds LoadAuthorizedCustomer / HasAccessToCustomer; a missing or out-of-scope customer still redirects to the customer list (Customer/List). Each action keeps its permission attribute and its scope checks. BaseCustomerController keeps the list, the form and the Info tab: 889 → about 400 lines, 22 → 11 constructor parameters, so List and Edit no longer construct the cart, pricing and tax services.

  • The address views (AddressCreate, AddressEdit, CreateOrUpdateAddress and the AddressDetails widget zone partials) moved to CustomerAddress/, the product picker popup to CustomerProduct/; the tab partials point at the new controllers.

  • Store panel: the RegisterCustomersPerStore gate was an OnActionExecutionAsync override on CustomerController, so it would not have covered the tab controllers. It is now a [PerStoreCustomers] filter on every customer controller in Grand.Web.Store, and a test fails if one is missing.

  • Fixes found in review:

    • GetCartList loads and checks the customer in both panels and passes it to the service. An unknown customer gives an empty grid instead of a NullReferenceException, and the store panel no longer reads the customer twice. In the Admin panel the cart grid now applies the same scope as the rest of the customer screen (a sales manager sees only their own customers).
    • DeleteCustomerNote(id, customerId) refuses a note that belongs to another customer; it used to delete by id alone.
  • Tests: the controller tests are split per controller on a shared CustomerControllerTestsBase fixture (as ProductControllerTestsBase). New tests cover both fixes, the note controller, the per-store filter, the attributes of every new controller, and the shopping cart, out-of-stock subscription and contact form view model services, which had none. The service tests of moved members are in CustomerProductViewModelServiceTests, CustomerNoteViewModelServiceTests, CustomerLoyaltyPointsViewModelServiceTests and CustomerAddressViewModelServiceTests.

Breaking changes

  • 26 members moved from ICustomerViewModelService to the new interfaces. Code outside this repository that calls one of them must inject the matching ICustomer<Tab>ViewModelService.
  • A class that implements ICustomerViewModelService, or subclasses CustomerViewModelService and overrides a moved virtual, must move that code to the new service. The CustomerViewModelService constructor lost the parameters only the moved members used.
  • The constructors of BaseCustomerController, BaseCustomerManagementController and the Admin and Store CustomerController changed: they lost the tab services and the dependencies only the tab actions used.
  • Routes: the tab actions moved to the new controllers, e.g. /Admin/Customer/GetCartList is now /Admin/CustomerShoppingCart/GetCartList (same for /Store/...). Nothing else in this repository calls the old URLs; a plugin or theme that does must use the new controller names.
  • Views: Customer/AddressCreate, Customer/AddressEdit, Customer/ProductAddPopup, Customer/Partials/CreateOrUpdateAddress and the WidgetZone.AddressDetails* partials moved to CustomerAddress/ and CustomerProduct/. A theme or plugin overriding them under the old folder must move the override. Widget zone names did not change.
  • No permission, setting or localization key changes.

Testing

  1. dotnet build of Grand.Web (combined host), Grand.Web.Admin, Grand.Web.Store and both test projects: no errors, no new warnings in the changed files.
  2. dotnet test: Grand.Web.Admin.Tests 1617 passed, Grand.Web.Store.Tests 184 passed.
  3. Fresh install with sample data, Customer:RegisterCustomersPerStore=true, driven by a Playwright script. As admin, on a customer with a wishlist:
    • every tab of the edit page opened in the browser with no 4xx/5xx and no console errors;
    • every tab grid returns 200 from its new controller;
    • loyalty points added; address created, edited and deleted through the real forms (form actions and the back link point at the right controllers; an unknown address redirects to Customer/Edit);
    • wishlist shows priced items; update returns the cart validation warning, delete removes the item; GetCartList for an unknown customer returns an empty grid;
    • product picker opens and searches; product price and personalized product added, updated and deleted;
    • note added and deleted; deleting it with another customer's id is refused and the note stays;
    • a POST without the antiforgery token is rejected with 400.
  4. As a store manager: the same walk through every tab, address forms, cart, product price and personalized product (there is no notes controller in the store panel).
  5. Store isolation, with a second store and a customer moved to it: the store manager does not see the customer in the list; the edit page and AddressCreate redirect to the list; cart, product prices, personalized products, orders, reviews and out-of-stock grids come back empty; loyalty history and addresses are refused; adding loyalty points returns Result: false; deleting the customer's product price changes nothing (checked afterwards as admin).
  6. With RegisterCustomersPerStore=false: Customer/List, CustomerAddress/AddressCreate, CustomerProduct/ProductAddPopup, CustomerShoppingCart/GetCartList and CustomerLoyaltyPoints/LoyaltyPointsHistorySelect in the store panel all redirect to Customer/PerStoreDisabled, which renders.
  7. The application log shows only the errors the negative checks provoke (the refused note deletion and the two requests without a token).

Address, loyalty points, shopping cart, products (prices and personalized),
out-of-stock subscriptions, notes and contact forms move to their own
ICustomer<Tab>ViewModelService. The moved members inject the services they
used to resolve from HttpContext.RequestServices. CustomerViewModelService
keeps the customer list and form, insert/update/delete and SendEmail.
Copilot AI balanced review requested due to automatic review settings October 22, 2026 19:11

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.

new CustomerModel.ProductPriceModel { Id = "pp1", Price = 99 });

_customerProductServiceMock.Verify(
c => c.UpdateCustomerProductPrice(It.Is<CustomerProductPrice>(x => x.Price == 99)), Times.Once);

_customerProductServiceMock.Verify(
c => c.InsertCustomerProductPrice(
It.Is<CustomerProductPrice>(x => x.CustomerId == "c1" && x.ProductId == "p1" && x.Price == 5)),
foreach (var x in addresses)
{
var model = await x.ToModel(countryService);
var addressHtmlSb = new StringBuilder("<div>");
Comment on lines +87 to +89
if (address != null)
if (!excludeProperties)
model.Address = await address.ToModel(countryService);
Each customer edit tab now has its own controller: Address, LoyaltyPoints,
ShoppingCart, Product, OutOfStockSubscription, Order and Review in
AdminShared with thin Admin and Store subclasses, plus Admin-only
CustomerNote and CustomerContactForm. They share BaseCustomerScopedController,
whose ownership check redirects to the customer list. BaseCustomerController
keeps the list, the form and the Info tab, and its constructor drops from 22
to 11 parameters, so List and Edit no longer build the cart, pricing and tax
services.

The store panel's per-store customer gate moves from a CustomerController
override to a [PerStoreCustomers] filter, applied to every customer
controller in Grand.Web.Store so the tab controllers are closed too when
RegisterCustomersPerStore is off.

Fixes:
- GetCartList loads and checks the customer in both panels and passes it to
  the service; an unknown customer gives an empty grid instead of a
  NullReferenceException, and the store panel no longer reads it twice.
- DeleteCustomerNote rejects a note that belongs to another customer.
- Removes the UTF-8 BOMs the previous commit added to existing files.

Adds tests for the shopping cart, out-of-stock subscription and contact form
view model services, the tab controllers, the note controller and the gate.

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

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

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);
Assert.IsFalse(nextCalled);
var redirect = context.Result as RedirectToActionResult;
Assert.IsNotNull(redirect);
Assert.AreEqual(nameof(CustomerController.PerStoreDisabled), redirect.ActionName);
@KrzysztofPajak KrzysztofPajak changed the title Split CustomerViewModelService into one service per customer edit tab Split CustomerViewModelService and the customer edit tabs into one service and controller per tab Oct 22, 2026
@KrzysztofPajak
KrzysztofPajak merged commit 44af492 into develop Oct 23, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the refactor/split-customer-viewmodel-service branch October 23, 2026 04:29
KrzysztofPajak pushed a commit that referenced this pull request Oct 23, 2026
Resolve conflicts with the customer tab split (#976) by taking develop's
controllers and services and injecting ICustomerMessageProviderService
in place of IMessageProviderService, including the new
CustomerNoteViewModelService.
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