Ecommerce
Storefront-facing customer data (wishlists, product reviews, last-viewed items, and addresses) inside the Sales plugin.
The ecommerce module lives inside sales_api (port 3305). It exposes authenticated GraphQL operations for staff, cp* operations marked forClientPortal for storefront customers, and unauthenticated HTTP summary routes for storefront bootstrapping.
Module layout
Backend code lives under backend/plugins/sales_api/src/modules/ecommerce/:
| Area | Path | Responsibility |
|---|---|---|
| Models | db/definitions, db/models | Wishlist, ProductReview, LastViewedItem, Address |
| GraphQL | graphql/schema, graphql/resolvers | Admin and cp* (Client Portal) operations |
| HTTP routes | routes.ts (mounted from src/routes.ts) | /ecommerce-* summary and bulk endpoints |
| tRPC | trpc/ecommerce.ts | Router file exists but is not merged into the plugin appRouter |
| Utils | utils.ts | Core products.find, customers.find, productCategories.find fetchers |
Data model
Wishlist:{ productId, customerId }pairs.ProductReview:{ productId, customerId, review: Float, description, info: JSON }.LastViewedItem:{ productId, customerId, modifiedAt }with a{ customerId, productId }index.Address:{ alias, customerId, coordinate: {lat,lng}, address1, address2, city, district, street, detail, more: JSON, w3w, note, phone }plus timestamps.
productId/customerId reference Core records; the module never stores product or customer data. It hydrates them over tRPC (core.products.find, core.customers.find, core.productCategories.find).
HTTP routes
Mounted on the plugin Express router (modules/ecommerce/routes.ts), reached through the gateway as /pl:sales/<path>. All are GET unless noted; all take customerId/productId as query params and return { status, data } or { status: 'error', errorMessage }:
| Route | Params | Returns |
|---|---|---|
GET /ecommerce-init | customerId | Customer summary: reviews + stats, wishlist, last 20 viewed items, addresses, hydrated products |
GET /ecommerce-product-summary | productId | Reviews, rating stats, wishlist/last-viewed counts, reviewer customers |
GET /ecommerce-last-viewed | customerId, limit (default 10) | Last-viewed items with hydrated products |
GET /ecommerce-wishlist | customerId | Wishlist with hydrated products |
GET /ecommerce-addresses | customerId | Address list |
GET /ecommerce-product-reviews | productId, limit (20), skip, sort (-createdAt) | Reviews with hydrated customers plus summary |
POST /ecommerce-bulk-operations | operations[] | Batch write: addToWishlist, addReview, addLastViewed, addAddress |
These routes are unauthenticated
/ecommerce-* routes check no token; any caller who knows a customerId can read that customer's wishlist, addresses, and review history. Front them with your own gateway auth if the storefront is public-facing.
Queries and mutations
Wishlist
| Operation | Kind | Notes |
|---|---|---|
wish(productId, customerId) | query | Single wish, hydrated product |
wishlist(customerId) | query | List, hydrated products |
wishlistAdd / wishlistUpdate / wishlistRemove | mutation | Staff-side mutations |
cpWishlist, cpWishlistAdd/Update/Remove | query/mutation | forClientPortal variants |
Product reviews
| Operation | Kind | Notes |
|---|---|---|
productReview(productId) | query | AverageReview: average + count |
productReviews(productIds, customerId, page, perPage) | query | Paginated list |
productReviewAdd/Update/Remove | mutation | All review mutations are skipPermission |
cpProductReviews, cpProductReview | query | forClientPortal |
cpProductReviewAdd/Update/Remove | mutation | skipPermission |
Last viewed items
| Operation | Kind | Notes |
|---|---|---|
lastViewedItems(customerId, limit) | query | Staff-side |
lastViewedItemAdd / lastViewedItemRemove | mutation | Staff-side |
cpLastViewedItems, cpLastViewedItemAdd/Remove | query/mutation | forClientPortal |
Addresses
| Operation | Kind | Notes |
|---|---|---|
address(_id) / addressList(page, perPage, searchValue, aliasType, customerId, city, district, street) | query | addressList is forClientPortal |
addressAdd / addressUpdate / addressRemove | mutation | addressAdd/addressUpdate are forClientPortal |
Permissions and context
The module registers no permission actions of its own. Standard operations run under the platform's default resolver wrapper, which only requires a logged-in user (checkLogin). There are no per-action ecommerce permissions.
Operations marked forClientPortal: true run under wrapPublicResolver instead (wrapperResolvers.ts): they require a Client Portal request context (context.clientPortal) and skip staff login. No ecommerce operation sets cpUserRequired, so a CP user session is not enforced; pass customerId explicitly. See Client Portal app for how that context is established.
tRPC
trpc/ecommerce.ts defines productReview, wishlist, lastViewedItem, and address sub-routers with findOne/find/aggregate/count/create/update/remove helpers, but the file is not imported by src/trpc/init-trpc.ts, so none of these procedures are reachable through the plugin's /trpc mount. Treat the router as dead code; use the HTTP routes or GraphQL instead.
Troubleshooting
Customer ID is required/Product ID is required: the routes read query params, not a body; use?customerId=.- Hydrated
productisnull: the referenced Core product was deleted or belongs to another tenant; list resolvers drop wishes whose product no longer exists. - tRPC call to
productReview.findfails: the ecommerce router isn't mounted; nothing to fix in config, use HTTP/GraphQL.