PRD: Menu organizer & channel visibility
| Module | Product (CORE-05) | PRD ID | PRD-SCH-001 |
| Status | Shipped | Owner | Product squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/commerce · @nx/core · @nx/search | URD | SCH · PRD |
TL;DR
Lets a merchant organize what it sells into sale channels - named, multilingual, hierarchical menu surfaces (in-store, takeaway, delivery, a kiosk) - and control, per channel, which products appear. A product is assigned to one or more channels at creation; the assignment is the visibility switch. Each channel carries a priority-ordered inventory-location chain so the right stock source is auto-picked at fulfillment, has a lifecycle status (activated / deactivated / archived), and cannot be deactivated while it still has active orders. Every channel and product list is served filtered by the caller's policy grants - an owner sees only their merchants' channels, an admin sees all - so the menu a device receives is always scoped to who is asking.
1. Context & Problem
The catalogue (PRD-PRD-001) defines the products a merchant sells, but not where each product is offered. A merchant rarely sells the same list everywhere: the dine-in menu differs from delivery, a kiosk shows a subset, a seasonal pop-up needs its own surface. Without a channel concept, every device would see one flat catalogue and the merchant could not turn a product on for delivery while keeping it off the counter.
Two more gaps follow. First, a channel must know where its stock comes from - which inventory location fulfills its orders - and needs a sensible default when several are possible. Second, the menu a device receives must be scoped to who is asking: an owner of two merchants must see exactly those two merchants' channels, an employee only their assigned merchant, an admin everything - without each caller hand-filtering.
This increment closes those gaps. It introduces the sale channel as the menu surface, the product-to-channel assignment as the visibility control, a priority-ordered inventory-location chain per channel, a lifecycle status with a deactivation guard, and policy-scoped serving for every channel and product read.
2. Goals & Non-Goals
Goals
- Let an owner create sale channels within a merchant - multilingual name/description, a system identifier, a per-merchant-unique slug.
- Make product-to-channel assignment the visibility control: a product appears in a channel only when assigned to it.
- Assign products to channels as part of the atomic product create, so a product ships visible on day one.
- Give each channel a lifecycle status (activated / deactivated / archived) and refuse deactivation while active orders remain.
- Carry a priority-ordered inventory-location chain per channel; the lowest-priority location is the auto-pick default.
- Support a parent-child channel hierarchy for grouping menu surfaces.
- Serve every channel and product list filtered by the caller's policy grants; admins bypass the filter.
Non-Goals
- Product, variant, and identifier definition - specified in PRD-PRD-001.
- Per-channel pricing tiers - handled by channel-context fares (FAR).
- Stock levels and movement - owned by Inventory; a channel only references a location, it does not hold stock.
- Walking the inventory chain as a multi-step fallback at fulfillment - only the default (lowest-priority) location is consumed today; the ordered chain is recorded for future use.
- Order processing and checkout through a channel - owned by Orders.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Visibility control | A product appears in a channel only when assigned to it; unassigning removes it |
| Scoped serving | A caller's channel/product list returns only their merchants' rows; admins see all |
| Deactivation safety | No channel with active orders can be deactivated |
| Stock-source clarity | Every channel resolves a single default inventory location with no ambiguity |
| Slug integrity | No two live channels in a merchant share a slug |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Manager | Build per-surface menus, turn products on/off per channel, set the channel's stock source |
| Cashier / Device | Receive the menu scoped to the current merchant and channel |
| Admin / Super Admin | Inspect channels across all merchants without role filtering |
Core scenario: an owner with a café creates a Delivery channel and a Counter channel. New products are assigned to Counter only; a subset is also assigned to Delivery, so the courier app shows a smaller menu. Delivery's inventory chain lists the back-store location first, so its orders auto-source from there. When the owner tries to deactivate Counter while a table still has an open order, the system refuses until the order is closed.
5. User Stories
- As an owner, I create a sale channel per selling surface, so each device shows the right menu.
- As an owner, I assign a product to the channels it should appear in, so visibility is a single switch.
- As an owner, I set a channel's inventory-location chain, so its orders auto-source from the right place.
- As an owner, I cannot accidentally deactivate a channel with live orders, so I never strand an open ticket.
- As an employee, I only ever see channels and products for merchants I'm assigned to, so my menu is never another merchant's.
- As an admin, I can read every merchant's channels without role filtering, so I can support any tenant.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Owner can create sale channels within a merchant, each with a multilingual name/description and an auto-generated system identifier | URD-SCH-001 |
| FR-2 | A channel slug is unique among live channels within the same merchant | URD-SCH-002 |
| FR-3 | A channel has a lifecycle status: activated, deactivated, archived | URD-SCH-003 |
| FR-4 | Owner can update a channel; an empty update (no fields) is refused | URD-SCH-004 |
| FR-5 | Deactivating a channel is refused while it still has active sale orders | URD-SCH-005 |
| FR-6 | Channels support a parent-child hierarchy | URD-SCH-006 |
| FR-7 | A product is assigned to one or more channels; the assignment controls whether it appears in that channel | URD-SCH-007 · URD-PRD-014 |
| FR-8 | Product-to-channel assignments are written as part of the atomic product create | URD-SCH-008 |
| FR-9 | A channel carries a priority-ordered inventory-location chain; the lowest-priority location is the auto-pick default; the chain diff-syncs on update | URD-SCH-009 |
| FR-10 | Deleting a channel unlinks its products and soft-deletes the channel | URD-SCH-010 |
| FR-11 | Channel and product lists/counts are served filtered by the caller's policy grants; admins bypass the filter | URD-SCH-011 · URD-ACC-001..004 |
| FR-12 | Channels and products are searchable, scoped to the caller's merchants | URD-SCH-012 |
| FR-13 | Creating a merchant's first channel completes the merchant's channel onboarding step | URD-SCH-013 |
Full requirement text and acceptance criteria live in the Product URD - SCH. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Atomicity | Channel create with its inventory chain is one transaction; product create writes its channel assignments in the same atomic step - partial failure rolls back fully |
| Tenancy & authz | All operations scoped per merchant (x-merchant-id) and gated by sale-channel permissions |
| Policy-scoped reads | Lists, counts, and search resolve the caller's merchant grants and filter to them; isAlwaysAllowed callers bypass |
| Uniqueness | Channel slug unique per merchant among live rows; an inventory location appears at most once per channel |
| Soft-delete | Channels and assignments are soft-deleted; nothing is physically removed |
| i18n | Channel name and description are bilingual ({ en, vi }) |
8. UX & Flows
The channel surface lets the owner name a channel, set its slug, order its inventory-location chain (top = default source), and place it under a parent. The product surface lets the owner pick the channels a product appears in. Devices request the menu and receive only the channels and products their policy grants allow.
9. Data & Domain
| Entity | Role |
|---|---|
SaleChannel | A menu surface within a merchant - name, slug, status, optional parent |
SaleChannelProduct | The visibility link - a product appears in a channel only while this link is live |
SaleChannelInventoryLocation | One location in the channel's priority-ordered stock-source chain (lowest priority = default) |
Conceptual only - full schema and invariants live in the commerce domain model. Relations are soft references; integrity is enforced in the channel and product services, not by database foreign keys.
10. Dependencies & Assumptions
Depends on
- Product catalogue (PRD-PRD-001, PRD) - products are what a channel makes visible.
- Commerce / Merchant (Commerce) - channels are scoped to a merchant; the first channel completes a merchant onboarding step.
- Inventory (Inventory) - the chain references inventory locations; a location must exist before it is added.
- Orders (Orders) - the deactivation guard reads a channel's active orders.
@nx/search- channels and products are indexed and served scoped to the caller's merchants.
Assumptions
- A merchant exists and the caller's policy grants resolve to the merchants they may see.
- Inventory locations referenced by a channel's chain belong to the same merchant.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| A channel left without a stock source | The chain's lowest-priority location is always the default; duplicates in the input collapse to the first occurrence |
| Deactivating a channel mid-service | Refused while active orders remain; owner must close or cancel them first |
| Cross-merchant menu leakage | Every list/count/search resolves the caller's grants and filters to them; only admins bypass |
| Slug collision within a merchant | Refused before persistence; slug is unique per merchant among live channels |
| Inventory chain walked as a fallback | Not today - only the default location is consumed; the ordered chain is recorded for future fulfillment logic |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P2 - SCH in the URD feature catalog |
| Rollout | All merchants; no feature flag |
| Migration | None - channels and assignments run on the existing commerce schema |
| Launch criteria | A product appears in a channel only when assigned; channel create resolves a default inventory location; deactivation with active orders is refused; every channel/product list is filtered to the caller's merchants; admins bypass |
| Monitoring | Deactivation-refusal rate, channels without a default location, cross-merchant list-scope checks |
13. FAQ
How do I hide a product from one channel but not another? Assign the product only to the channels it should appear in - the assignment is the visibility switch. Unassigning removes it from that channel without touching the others.
What is a channel's "default" inventory location? The first one in the channel's chain - the lowest priority value. Orders on that channel auto-source from it. You can reorder the chain on update; the rest is recorded for future fallback use.
Why can't I deactivate a channel? Because it still has active orders. Close or cancel them first, then deactivate.
Does an admin see every merchant's channels? Yes - admin and super-admin callers bypass role filtering; every other caller sees only the channels of merchants they are granted.
Can channels be nested? Yes - a channel can have a parent, so menu surfaces can be grouped.
References
- URD: Product - SCH · PRD · ACC
- Related PRDs: Product catalogue, variants & identifiers · Fare system & pricing
- Module: Product - overview + capabilities
- Developer: @nx/commerce · @nx/core · @nx/search