Restaurant Bundle / Combo Architecture
Document version: 1.0 · Last updated: 2026-03-14 Spec: docs/restaurant/bundles-modifiers-restaurant_combos.md
Table of contents
- Overview
- Database schema
- Backend module
- API reference
- Request / response flows
- Kitchen routing
- PWA display
- End-to-end setup guide
- Related files
Overview
Restaurant combos extend the base bundle system with:
| Feature | Retail bundles | Restaurant combos |
|---|---|---|
| Component groups | Denormalised group_key string on each component | First-class bundle_component_group table |
| Order item structure | Flat order_item rows | Parent/child hierarchy (bundle_parent → bundle_child) |
| Modifier persistence | Legacy JSON blob on order_item.modifiers | Structured order_item_modifier rows |
| Bundle application audit | applied status only | Full lifecycle: applied → removed, with pricing_snapshot |
| Kitchen routing | All items route | bundle_parent items skipped; only bundle_child items route |
Database schema
bundle_component_group
One row per selectable group within a bundle (e.g. "Choose your main", "Choose a side").
bundle_component_group
├── id uuid PK
├── bundle_id uuid FK → bundle.id ON DELETE CASCADE
├── group_key varchar(100) machine-stable key (e.g. "main")
├── name varchar(150) display label
├── min_select integer DEFAULT 0
├── max_select integer DEFAULT 1
├── is_required boolean DEFAULT true
├── allow_duplicates boolean DEFAULT false
├── sort_order integer DEFAULT 0
├── created_at / created_by / updated_at / updated_by
UNIQUE (bundle_id, group_key)
bundle_component (new columns)
bundle_component
├── bundle_component_group_id uuid FK → bundle_component_group.id ON DELETE CASCADE (nullable)
├── role bundle_component_role DEFAULT 'required'
│ values: required | optional | trigger | target
├── price_adjustment numeric(20,6) DEFAULT 0
└── is_default boolean DEFAULT false
order_item (new columns)
order_item
├── line_type order_item_line_type DEFAULT 'product'
│ values: product | bundle_parent | bundle_child
├── parent_order_item_id uuid FK → order_item.id ON DELETE CASCADE (nullable, self-ref)
├── bundle_id uuid FK → bundle.id ON DELETE SET NULL (nullable)
└── bundle_component_group_id uuid FK → bundle_component_group.id ON DELETE SET NULL (nullable)
Hierarchy invariants:
bundle_parent—product_id = null,unit_price_snapshot = 0,parent_order_item_id = nullbundle_child—product_idset, real price,parent_order_item_id= parent's idproduct— normal standalone item (default, unchanged)
Deleting a bundle_parent row cascades to all its bundle_child rows and their order_item_modifier rows via FK.
order_item_modifier
Structured modifier record replacing the legacy order_item.modifiers JSON blob for combo items.
order_item_modifier
├── id uuid PK DEFAULT gen_random_uuid()
├── order_item_id uuid FK → order_item.id ON DELETE CASCADE
├── product_modifier_group_id uuid FK → product_modifier_group.id ON DELETE SET NULL (nullable)
├── product_modifier_id uuid FK → product_modifier.id ON DELETE SET NULL (nullable)
├── quantity numeric(20,6) DEFAULT 1
├── unit_price_adjustment money_minor DEFAULT 0
├── total_price_adjustment money_minor DEFAULT 0
├── name_snapshot varchar(150) NOT NULL
├── kitchen_label_snapshot varchar(150)
└── created_at / created_by
bundle_application (new columns)
bundle_application
├── status bundle_application_status DEFAULT 'applied'
│ values: applied | removed
├── pricing_snapshot jsonb DEFAULT '[]'
├── removed_at timestamptz
├── removed_by uuid FK → user.id ON DELETE SET NULL
└── removal_reason text
Migrations (in order)
| File | Change |
|---|---|
2026-03-14t00-01-00-bundle-component-groups.mjs | Creates bundle_component_group |
2026-03-14t00-02-00-bundle-component-enhancements.mjs | Adds role, price_adjustment, is_default, bundle_component_group_id to bundle_component |
2026-03-14t00-03-00-order-item-bundle-hierarchy.mjs | Adds line_type, parent_order_item_id, bundle_id, bundle_component_group_id to order_item |
2026-03-14t00-04-00-order-item-modifiers.mjs | Creates order_item_modifier |
2026-03-14t00-05-00-bundle-application-lifecycle.mjs | Adds status, pricing_snapshot, removed_* to bundle_application |
2026-03-14t00-06-00-bundle-component-group-backfill.mjs | One-time: creates bundle_component_group rows from existing bundle_component.group_key data and links components via FK |
Backend module
apps/backend/src/bundles/
├── domain/bundles-repository.domain.ts
│ ├── IBundlesRepository bundle + component + group CRUD
│ ├── IBundleApplicationRepository apply() / markRemoved()
│ └── IRestaurantBundleRepository order_item hierarchy + modifiers
│
├── infrastructure/
│ ├── bundles.repository.ts BundlesRepository + BundleApplicationRepository
│ │ ├── findActiveBundlesForBusiness() returns components[] + groups[]
│ │ ├── createGroup / findGroupsByBundleId / updateGroup / deleteGroup
│ │ └── BundleApplicationRepository.markRemoved()
│ │
│ └── restaurant-bundle.repository.ts RestaurantBundleRepository
│ ├── createBundleParentOrderItem()
│ ├── createBundleChildOrderItem()
│ ├── createOrderItemModifiers() bulk INSERT
│ ├── findOrderBundleTree() parent + children + modifiers
│ └── removeBundleTree() DELETE parent (cascades children + modifiers)
│
├── application/bundles.service.ts
│ ├── createGroup / findGroupsByBundle / updateGroup / deleteGroup
│ └── buildRestaurantBundle(orderId, businessId, dto)
│
└── interfaces/
├── bundles.controller.ts
└── dtos/
├── create-bundle-component-group.dto.ts
└── build-restaurant-bundle.dto.ts
RestaurantBundleRepository is registered in BundlesModule and exported, so the restaurant OrderModule (which imports BundlesModule) can inject it into OrdersService.
API reference
Group CRUD
POST /bundles/:id/groups?businessId=
GET /bundles/:id/groups?businessId=
PATCH /bundles/:id/groups/:groupId?businessId=
DELETE /bundles/:id/groups/:groupId?businessId=
CreateBundleComponentGroupDto
{
"group_key": "main",
"name": "Choose your main",
"min_select": 1,
"max_select": 1,
"is_required": true,
"allow_duplicates": false,
"sort_order": 0
}
Build combo
POST /bundles/:id/combo?orderId=<uuid>&businessId=<uuid>
BuildRestaurantBundleDto
{
"employee_id": "uuid",
"bundle_id": "uuid",
"selections": [
{
"group_id": "uuid",
"product_id": "uuid",
"quantity": "1",
"seat_no": 1,
"modifiers": [
{
"product_modifier_group_id": "uuid",
"product_modifier_id": "uuid",
"name_snapshot": "Extra sauce",
"quantity": "1"
}
]
}
],
"notes": "optional order notes"
}
Response: BuildRestaurantBundleResult
{
"parentOrderItemId": "uuid",
"childOrderItemIds": ["uuid", "uuid"],
"modifierIds": ["uuid"],
"bundleApplicationId": "uuid"
}
Legacy combo (updated)
POST /orders/:id/bundles/add-combo
Uses the older { component_id, product_id, qty }[] selection shape. As of 2026-03-14 this endpoint also produces the full bundle_parent / bundle_child hierarchy in order_item.
Request / response flows
buildRestaurantBundle()
POST /bundles/:id/combo
│
▼
BundlesService.buildRestaurantBundle()
│
├─ 1. BundlesRepository.findByIdWithComponents()
│ Validate: bundle active, mode = restaurant, within valid dates
│
├─ 2. Validate selections
│ • Every is_required group must have at least one selection
│ • Each selection's product_id must match a valid component
│
├─ 3. RestaurantBundleRepository.createBundleParentOrderItem()
│ INSERT order_item { line_type='bundle_parent', bundle_id, qty, price=0 }
│
├─ 4. For each selection:
│ RestaurantBundleRepository.createBundleChildOrderItem()
│ INSERT order_item { line_type='bundle_child',
│ parent_order_item_id=parentItem.id,
│ bundle_id, bundle_component_group_id,
│ product_id, price }
│
├─ 5. RestaurantBundleRepository.createOrderItemModifiers()
│ bulk INSERT order_item_modifier rows for each child's modifiers
│
└─ 6. BundleApplicationRepository.apply()
INSERT bundle_application { status='applied', pricing_snapshot }
addOrderCombo() (legacy, updated)
POST /orders/:id/bundles/add-combo
│
▼
OrdersService.addOrderCombo()
│
├─ RestaurantBundleRepository.createBundleParentOrderItem()
│
├─ For each selection:
│ OrderItemRepository.create({ line_type='bundle_child',
│ parent_order_item_id, bundle_id,
│ bundle_component_group_id })
│
├─ BundlesService.applyBundle() ← pricing + audit record
│
└─ Tag parent + children with bundleApplicationId
Resulting order_item tree
order_item (bundle_parent)
id: "aaa"
line_type: "bundle_parent"
bundle_id: "bundle-uuid"
product_id: null
unit_price_snapshot: 0
order_item (bundle_child — burger)
id: "bbb"
line_type: "bundle_child"
parent_order_item_id: "aaa"
bundle_component_group_id: "main-group-uuid"
product_id: "burger-uuid"
unit_price_snapshot: 850 ← in minor units
order_item (bundle_child — cola)
id: "ccc"
line_type: "bundle_child"
parent_order_item_id: "aaa"
bundle_component_group_id: "drink-group-uuid"
product_id: "cola-uuid"
unit_price_snapshot: 0
order_item_modifier
order_item_id: "ccc"
name_snapshot: "No ice"
unit_price_adjustment: 0
Kitchen routing
OrdersService.sendOrderToKitchen() filters items before routing:
allItems
.filter(item =>
!item.voidedAt &&
(!item.holdUntilFired || item.firedAt) &&
item.lineType !== 'bundle_parent' ← excluded: no productId, synthetic header
)
→ resolveStationForProduct(item.productId, item.modifiers)
→ createPrintJob(orderItemId, stationId)
bundle_parent rows never get a print job. Each bundle_child is routed individually to the kitchen station that handles its productId. The KDS therefore shows individual components, not a single "combo" line.
PWA display
apps/frontend-pwa/src/components/forms/restaurant/ItemsSection.tsx
The flat order.items array is re-ordered in the orderedItems memo so that each bundle_child appears immediately after its bundle_parent:
orderedItems memo
├── bundle_parent row → purple background, Package icon, no price cells
│ bundle_child row → left-border indent, shows product name + price
│ bundle_child row → ...
├── product row → normal rendering
└── ...
itemsForTotals
filters out bundle_parent (price = 0)
only bundle_child + product rows contribute to subtotal / tax totals
Types: RestaurantOrderItem in apps/frontend-pwa/src/types/restaurant.ts includes:
lineType?: 'product' | 'bundle_parent' | 'bundle_child' | null
parentOrderItemId?: string | null
bundleId?: string | null
bundleComponentGroupId?: string | null
orderItemModifiers?: OrderItemModifier[]
End-to-end setup guide
1. Create the bundle
POST /bundles?businessId=&createdBy=
{
"name": "Combo #1",
"mode": "restaurant",
"priceMethod": "fixed_price",
"price": 1500,
"isActive": true
}
2. Create groups
POST /bundles/:id/groups?businessId=
{ "group_key": "main", "name": "Choose your main", "min_select": 1, "max_select": 1, "is_required": true, "sort_order": 0 }
{ "group_key": "side", "name": "Choose a side", "min_select": 1, "max_select": 1, "is_required": true, "sort_order": 1 }
{ "group_key": "drink", "name": "Choose a drink", "min_select": 0, "max_select": 1, "is_required": false, "sort_order": 2 }
3. Add components to groups
POST /bundles/:id/components?businessId=
{ "product_id": "<burger-id>", "bundle_component_group_id": "<main-group-id>", "role": "required" }
{ "product_id": "<chicken-id>", "bundle_component_group_id": "<main-group-id>", "role": "optional" }
{ "product_id": "<fries-id>", "bundle_component_group_id": "<side-group-id>", "role": "required" }
{ "product_id": "<salad-id>", "bundle_component_group_id": "<side-group-id>", "role": "optional" }
{ "product_id": "<cola-id>", "bundle_component_group_id": "<drink-group-id>", "role": "optional" }
4. Build a combo on an order
POST /bundles/:bundleId/combo?orderId=<order-id>&businessId=<business-id>
{
"employee_id": "<employee-uuid>",
"bundle_id": "<bundle-uuid>",
"selections": [
{ "group_id": "<main-group-id>", "product_id": "<burger-id>", "quantity": "1" },
{ "group_id": "<side-group-id>", "product_id": "<fries-id>", "quantity": "1" },
{ "group_id": "<drink-group-id>", "product_id": "<cola-id>", "quantity": "1",
"modifiers": [
{ "product_modifier_group_id": "<mod-group-id>",
"product_modifier_id": "<no-ice-id>",
"name_snapshot": "No ice" }
]
}
]
}
5. Send to kitchen
POST /orders/:orderId/send-to-kitchen
bundle_parent is skipped. Each bundle_child item routes to the station for its product.
6. Remove combo
POST /orders/:orderId/bundles/remove
{ "employee_id": "...", "bundle_application_id": "..." }
bundle_application.status is set to 'removed'. The bundle_parent row is deleted, cascading to all children and their modifiers.
Related files
| Path | Role |
|---|---|
| docs/restaurant/bundles-modifiers-restaurant_combos.md | Original spec |
| docs/bundles/README.md | Base bundle system overview |
| docs/bundles/bundle-workflows.md | Apply / remove / evaluate workflows |
| docs/architecture/document-line-item-shape.md | Line item shape contract + combo hierarchy section |
packages/backend/database/src/migrations/2026-03-14t* | All 6 restaurant combo migrations |
apps/backend/src/bundles/ | Full backend module |
apps/backend/src/restaurant/infrastructure/restaurant-bundle.repository.ts | Order item hierarchy + modifier persistence |
apps/frontend-pwa/src/components/forms/restaurant/ItemsSection.tsx | Combo display in PWA |
apps/frontend-pwa/src/types/restaurant.ts | RestaurantOrderItem + OrderItemModifier types |