User guide

Product Catalog

Looking for what it does rather than how to use it? Read the Product Catalog overview .

Distributor feeds, Icecat enrichment, the MSP store and the customer storefront are documented separately.


What it is

The Product Catalog is the single, platform-wide list of hardware and software products that everything else in Solidlio points at — quotes, orders, assets, subscriptions and the storefront. It is global, not per-tenant: there is one catalog, every account reads the same rows, and only platform and MSP administrators can change them.


Concepts

ObjectTableWhat it is
BrandbrandsA manufacturer. Carries aliases, a logo, support URLs, and an optional parent brand.
Categorycatalog_categoriesA node in the product taxonomy. Self-referencing parent/child hierarchy.
Catalog productcatalog_productsOne sellable item. Belongs to exactly one brand and at most one category.
Imagecatalog_product_imagesProduct photography, one flagged isMain, ordered by sortOrder.
Featurecatalog_product_featuresOne specification row (name, value, unit), optionally grouped by feature group.
Documentcatalog_product_documentsA manual, datasheet or driver bundle attached to a product.
Distributor stockdistributor_stockPer-distributor cost and quantity for a product. Read-only from this service.

Relationships

Brand ──< CatalogProduct >── CatalogCategory

             ├──< CatalogProductImage
             ├──< CatalogProductFeature ──> FeatureGroup
             ├──< CatalogProductDocument
             ├──< DistributorStock ──> Distributor
             └──1 Product (internal billing product, optional)

Product identity

A product is identified four ways, and you need to understand which is which:

FieldUniquenessWho sets it
skuUnique across the catalogYou, or the importer. Rejected as a duplicate on create and on rename.
eanNot uniqueGlobal trade number, indexed.
upcNot uniqueNorth American trade number, indexed.
icecatIdUnique when presentSet by matching, not by hand.

Only sku is enforced unique. Two rows may legitimately share an MPN if they belong to different brands.

Product status

Six values exist; the catalog is sequenced through them by hand or by bulk action.

StatusMeaning
ACTIVESellable. The only status GET /products/search returns.
DRAFTImported or created but not released.
ARCHIVEDWithdrawn, kept for history.
DISCONTINUEDWithdrawn. Also what a delete becomes when assets reference the product.
OUT_OF_STOCKTemporarily unsellable.
COMING_SOONAnnounced, not yet sellable.

Cost columns

Four cost-shaped columns exist and they do not mean the same thing.

lowestCost and highestCost are computed over available stock only, so they agree between GET /products/:id and GET /products/:id/stock.


Roles and permissions

The catalog is global, so the rule is simple: anyone signed in can read it, only MSP administrator and above can change it.

Actionplatform administratorMSP administratorMSP technicianorganization administratorpower userCUSTOMER
Read products / brands / categories
Create / edit / delete any of them
Bulk status change, bulk delete
Match / enrich a product against Icecat
See named distributor relationships on a brand

Enforcement:

There is no plan gate on the catalog.


Walkthroughs

The working catalog UI lives in the platform admin portal. All three pages are in the left-hand nav under Catalog.

Browse and filter products

  1. Go to Platform → Catalog → Products (/platform/catalog/products).
  2. Use the filter bar to narrow the list:
    • Search matches name, sku, mpn and ean as a substring, and is debounced 400 ms.
    • Brand, Category and Status are exact-match dropdowns.
    • Clear Filters resets all four.
  3. Sortable fields are name, sku, createdAt, updatedAt, listPrice, costPrice and brand.
  4. Page size is selectable at the bottom of the table; the API caps a page at 100 rows.
  5. Click a row to open the detail drawer, or View Full to open the full product page at /platform/catalog/products/[id].

Create a product

  1. On the products page, click New Product.
  2. SKU, Name and Brand are required. Everything else is optional.
  3. If you enter a Cost Price, set the Cost Currency with it.
  4. Save. A duplicate SKU is refused with 409 Product with this SKU already exists.

Change a cost price

Cost lives on a globally shared row that every store’s pricing engine reads, so the service refuses an ambiguous edit:

  1. Open the product and click Edit.
  2. Change Cost Price.
  3. You must also send Cost Currency. If you change the number without it and the product already has a cost recorded, the save is refused with:

Send costCurrency alongside costPrice. This product’s cost is currently recorded in CAD; changing the number without the currency would re-price it in every store that sells it.

This check only fires when the numeric value actually changes; re-saving the same cost is allowed.

Retire products in bulk

  1. Select rows with the checkboxes. The bulk bar appears at the bottom.
  2. Choose Change Status and pick the target status, or Delete.
  3. A bulk call accepts at most 100 IDs.
  4. Delete is not unconditional. Any selected product that has customer assets linked to it is set to DISCONTINUED instead of removed, and the response tells you how many:

3 products were discontinued instead of deleted because they have linked assets

The soft-deletes and hard-deletes run in one transaction.

The same protection applies to a single DELETE /products/:id, which answers Product discontinued (has linked assets) rather than removing the row.

Manage brands

  1. Go to Platform → Catalog → Brands (/platform/catalog/brands).
  2. The list shows product count, distributor-mapping count and child-brand count per brand, and can be filtered by search or active state. A brands page may request up to 1,000 rows at a time.
  3. New Brand creates one. Names must be unique — a collision returns 409 Brand with this name already exists.
  4. Aliases are alternative names (for example Polycom and Plantronics for Poly). They are matched by the brand search and by the import matchers.
  5. Parent Brand records corporate ownership. It does not merge products.
  6. Deleting a brand that has products deactivates it instead (Brand deactivated (has associated products)); a brand with no products is removed outright.

Manage categories

  1. Go to Platform → Catalog → Categories (/platform/catalog/categories).
  2. New Category creates one; pick a Parent to nest it.
  3. Renaming or re-parenting a category through the API recomputes its materialised path and depth and rewrites every descendant.
  4. A category cannot be deleted while it still has children (Cannot delete category with subcategories) or products (Cannot delete category with products). Move or reassign first.

Configuration

SettingEffectIf unset
NODE_ENVproduction switches Morgan to combined logging and hides error detail from clients.Development-style logs and full error messages.

Rate limiting inside the service is defence in depth only — 1,000 requests per 15 minutes, skipped for /health and in tests.

There are no catalog-specific feature flags, and no per-tenant catalog settings: the catalog is one shared dataset.


Troubleshooting

Symptom / messageCauseFix
403 Forbidden on any create/edit/deleteCaller is below MSP administrator. Reads are open, writes are not.Use an MSP administrator or platform administrator session.
409 Product with this SKU already existsSKU is unique across the whole catalog, including on rename.Pick a different SKU or edit the existing row.
400 Brand not foundbrandId on create does not resolve.Create the brand first, or pick from the dropdown.
400 Send costCurrency alongside costPrice…You changed a cost number on a product that already had one, without saying what currency it is in.Send costCurrency in the same request.
409 Brand with this name already existsExact name collision.Add the variant as an alias on the existing brand instead of a new brand.
400 Cannot delete category with subcategories / …with productsDelete is blocked while anything hangs off the category.Re-parent the children, or move the products, then delete.
400 Category cannot be its own parentYou selected the category itself as its parent.Pick a different parent.
400 Parent category not foundparentId does not resolve.Refresh the tree; the parent may have been deleted.
400 Status is required for updateStatus actionBulk updateStatus sent without a status.Include the target status.
400 Product has no MPN, EAN, or UPC to match againstIcecat matching needs at least one manufacturer identifier.Fill in the MPN and retry.
400 Product has no Icecat ID — must be matched firstEnrichment was called before matching.Run Match first, then Enrich.
400 Icecat credentials not configuredIcecat import attempted with no credentials in the environment.Configure Icecat, or create the product by hand.
Delete “succeeds” but the product is still listedIt had linked assets and was discontinued, not deleted. The message says so.Filter the list by status to confirm; this is intended.
400 Category cannot be moved under one of its own descendantsThe requested parent sits inside the subtree being moved, which would make the tree a cycle.Re-parent the descendant out of the way first, or pick a different parent.

Limits and known behaviour

Read this section before you promise anything to a customer.

Cost is staff-only; distributor identity is platform-only. Two separate rules, both derived from the trusted gateway identity, never from a query parameter:

DataWho sees it
costPrice, costCurrency, lowestCost, averageCost, highestCost, per-distributor unitCostMSP technician and above
Which distributor supplies a product (name, code)Platform only
Availability — quantity, lead time, in-stock flagEvery authenticated caller

The keys are absent for a caller who may not see them, not null: a costPrice: null is indistinguishable from “no cost recorded”.

organization administrator and below are excluded deliberately: on this platform an organization admin is the customer, and the cost is their provider’s margin.

Before this was fixed, every read path returned cost to any signed-in session, and /stock named each supplier and its unit cost to a CUSTOMER-role caller. The write floor only ever guarded writes.

The catalog is global and unversioned, but no longer unattributed. There is still no per-tenant catalog and no draft/publish workflow: an edit by one MSP administrator is immediately live for every account. Product, category and brand creates, updates and deletes now write an audit entry recording the actor (id, email and role, denormalised so the evidence survives user deletion), the field-level before/after diff, and the originating IP. Read them in the audit log filtered on entityType = CatalogProduct / CatalogCategory / Brand. There is no per-product history view in the product UI yet.

Search is a substring scan, not an index lookup. search uses unanchored LIKE %term% across four to six columns.

% and _ in a search term are searched literally. They are escaped before reaching LIKE, so searching for 50% finds products whose name contains 50% rather than matching every row in the table. Escaping is applied on every search path in the service — products, brands, categories, vendors, software, distributor mappings and the Icecat index browser — via escapeLike from @solidlio/utils/like.

Previously these were passed through unescaped, so % matched every product and _ matched every product, on the product list, search, export, brand list and category list. The query was always parameterised, so this was never injection — but a filter that silently over-matches tells the caller they narrowed a set when they did not.

Brand names are matched case-sensitively. Lenovo and lenovo are two different brands and both will be accepted. With 30,231 brands in the dataset, case-variant duplicates split a manufacturer’s products across two rows.

Category path and depth are only maintained through the API. Categories created by the reference sync and the bulk category-mapping scripts get a parentId but no path and no depth. In the development dataset 1,358 of 1,360 categories have a parent, yet every one has path = NULL and depth = 0. GET /categories/tree still nests correctly because it builds from parentId, but any consumer that reads path or depth gets nothing useful.

Re-parenting a category under its own descendant is refused. The check walks the full ancestor chain, so a cycle of any length answers 400 Category cannot be moved under one of its own descendants. Nesting is also capped at 32 levels, and the descendant-path rewriter carries its own visited-set and depth ceiling so a tree corrupted before this guard existed terminates (with an error logged) instead of rewriting rows forever.

Previously only self-parenting (parentId === id) was rejected. A deeper cycle was accepted, the parent update committed, and then the path rewriter looped around the cycle.

CSV export does not work. The format=csv branch of GET /products/export returns text/csv, but the typed client parses every response as JSON. The export modal defaults to CSV. Use JSON export until this is fixed.

There is no CSV import. The Import tab on the products page says “from Icecat or CSV files”; only the Icecat path exists.

Four category fields cannot be set from the UI. Fee Category, Visibility, Display Order and Platform Managed are rendered as editable controls on the category form but are never included in the save payload, and the typed client has no fields for them. The backend accepts all four — set them via the API.

106,230 products in the development dataset are flagged for review with no way to see or clear the flag from the product UI.

Bulk operations are capped at 100 IDs and export at 10,000 rows. Neither is configurable.

metaTitle, metaKeywords and metaDescription are dead columns. Nothing in the codebase writes or reads them.


Verification

This guide was written against the product, not a specification.

  • Re-measured 2026-07-28; the Icecat backfill is still running, so these grow.
  • Search cost measured with EXPLAIN ANALYZE on that dataset.
  • Every UI path named above was confirmed to exist and to be linked from the platform navigation.

Questions this guide did not answer?

Ask us. You will get a reply from someone who uses the product every day.

Book a demo Contact us

A 30-minute walkthrough against your own workflow. No slides.