RFC: Product-level catalog API (base products + fan-out)
Add product-level createBaseProduct / updateBaseProduct / ... procedures that fan out one author payload into N catalog_product SKU rows — without introducing a new table.
A product-level tRPC surface (createBaseProduct, updateBaseProduct, getBaseProduct, listBaseProducts, deleteBaseProduct) on the existing v3Admin.catalog router. Author one payload, get N fanned SKU drafts.
No schema change. A base product remains derived — just baseProductId denormalized across sibling rows. Live/draft model and publishDrafts are untouched.
Only RFC + glossary. No code, no tests. The PR is design artifacts from a /grill-with-docs session, intended to unblock implementation in a follow-up.
1. Why this exists
Today's v3Admin.catalog.* router is SKU-grained. createProduct's own comment says so: "Creating a single SKU here; 'create a whole product with N SKUs' is a separate op."
But real catalog items are base products with several priced variants — Voice AI with per-minute / load-tracked / load-booked flavors, or a region × tier grid. Authoring those one row at a time is tedious, error-prone, and the API offers no concept of "the common parts are shared." This RFC defines that missing product-level surface.
- Base product
- Derived grouping —
baseProductIdshared across sibling SKU rows. Not a table. - Variant (SKU)
- One
catalog_productrow. Invariant: SKU == unique price. - Common attr
- Shared verbatim across all variants. May be scalar or array.
- Distinguishing
- Flagged
unique_price: true. The fan-out dimensions. - Fan-out
- Full cartesian product of distinguishing dimensions → one variant per combination.
2. The fan-out, made concrete
Given region: ['US', 'EU'] distinguishing and tier: ['gold', 'silver', 'bronze'] distinguishing, plus common attributes like productLine: 'usage' common, the backend stages 2 × 3 = 6 draft SKU rows. The author must supply exactly 6 prices, one per cell:
Each cell becomes one catalog_product draft row. Its attribute map is { ...common, ...thisCombination }, split by key into systemAttributes / customAttributes via the existing productSpecToRow. skuId is deterministic — e.g. voice-ai__region-us__tier-gold — so re-creates are idempotent.
3. The API surface
| Procedure | What it does | Returns |
|---|---|---|
createBaseProduct |
Fans cartesian product → stages N draft rows, all-or-nothing. | { products: ProductDTO[] } |
updateBaseProduct |
Partial-merge common attrs / rename across every sibling. No prices, no structural changes. | { products: ProductDTO[] } |
getBaseProduct |
All variants for one base (preview view draft ?? live). NOT_FOUND if empty. |
{ products: ProductDTO[] } |
listBaseProducts |
Preview variants for the org; client groups by baseProductId. |
{ products: ProductDTO[] } |
deleteBaseProduct |
Stages isDeleted=true on every sibling; batched soft-delete. |
{ skuIds: string[], staged: 'delete' } |
The createBaseProduct payload
Note: the attributes block does double duty — entries flagged unique_price become fan-out dimensions; everything else is common. An array-valued common attribute (like listPriceCurrencies) is NOT auto-promoted to a dimension.
createBaseProduct({
baseProductId: 'voice-ai',
baseProductName: 'Voice AI',
attributes: {
productLine: 'usage', // common (scalar)
listPriceCurrencies: ['USD', 'EUR'], // common (array — NOT a dimension)
region: { values: ['US', 'EU'], unique_price: true }, // distinguishing
tier: { values: ['gold','silver','bronze'], unique_price: true },
},
prices: [
{ region: 'US', tier: 'gold', price: { flat_price: { amount: 120, ... } } },
// … all 6, exactly one per combination …
],
})
4. Completeness validation
Before any row is staged, the backend validates the price set against the expected cartesian product. All failures are aggregated into one response — no fail-on-first.
- Expected count =
∏ |values| - Every combination appears exactly once
- Each
prices[i]names exactly the flagged keys - Each
pricepassesproductPriceSchema - All failures aggregated
Expected 6 prices (region[2] × tier[3]); got 5.
• Missing: { region: "EU", tier: "bronze" }
• Unrecognized: { region: "APAC", tier: "gold" }
— "APAC" not in option "region" (allowed: US, EU)
• Duplicate: { region: "US", tier: "gold" } ×2
• Entry 4 missing required option "tier"
5. updateBaseProduct — what it deliberately cannot do
Update is scoped to common attributes + rename only. The payload's shape makes price changes structurally impossible — there's nowhere to put them.
baseProductName(rewrites denormalized name on every sibling)systemAttributespartial mergecustomAttributespartial merge
- Prices (not a field — stay on SKU-level API)
- Add/remove a dimension value (re-fan not in v1)
- Patch keys that are currently distinguishing dimensions (runtime reject)
- Delete a key (partial merge only overwrites)
6. Architecture — thin procedures over draft-staging helpers
Same principle as the existing SKU-level CRUD. The base procedures wrap upsertDraftProduct with new base-level helpers:
loadBaseProductRows(orgId, baseProductId)— readstageCreateBaseProductDrafts/stageUpdateBaseProductDrafts/stageDeleteBaseProductDrafts— each fans over siblings, delegates toupsertDraftProduct
upsertDraftProduct imports the global Prisma client and is not transaction-parametric. Staging N variants all-or-nothing needs either a tx-capable write helper or wrapping the loop in prisma.$transaction — same gap noted in the earlier catalog-product RFC for bulk staging.
7. Key decisions
Base product stays a baseProductId + denormalized name. Alt: a real catalog_base_product table. Why this: two-way door, no migration, collapse/publish already keys off baseProductId.
unique_price: true is required to mark a dimension. Inferring from "is it an array?" would misclassify legitimate array-valued commons like listPriceCurrencies.
No per-cell holes in v1. Removes the "did I enumerate every combination?" foot-gun and lets validation be a clean completeness check.
Whether region exists as an sku AttributeDef and whether its values are allowed is tracked separately in DEA-6324.
8. What this PR doesn't change
- No Prisma schema migration —
catalog_productis unchanged. - No new tRPC procedures land yet — this PR is RFC + glossary only.
- SKU-level CRUD (
createProduct,updateProduct,deleteProduct, ...) is unchanged and still supported. publishDraftsflow is unchanged.- The system-vs-custom attribute split (
SYSTEM_ATTRIBUTE_KEYSviaproductSpecToRow) is unchanged — base procedures reuse it. - No change to V2 publish path… except for the gap below.
9. Open questions
collapseRowsByBaseProduct + VARIANT_TYPE_MAP (dealops3/catalogProduct/collapseSiblings.ts) is single-discriminator. A region × tier base product can be created via this API but won't publish to V2 — collapse throws. RFC recommendation: generalize collapse to compound predicates in a separate ticket; let this API land first since it only writes rows.
10. Files in this PR
+139 / new file
The RFC itself: problem framing, current state, full API design (signatures + validation + error shapes), architecture, decisions log, rollout plan, open questions.
+80 / modified
First catalog vocabulary block (base product, variant, common/distinguishing, fan-out, system-vs-custom), relationship rules, and seven dated decision-log entries for 2026-06-23.