Menus & Modifier Groups — Developer Reference
Admin UI for restaurant menus (
restaurantMenus) and per-product modifier configuration. Covers PWA components, state, REST API, full schema column reference, validation/error codes, delete/archive rules, order persistence, POS flow, and downstream consumers (orders, pricing, KDS).Last verified against:
apps/frontend-pwaMenus page ·apps/backendMenuModule/ProductModifiersController
Quick links: Restaurant architecture · Restaurant PWA screens · Bundles & modifiers (combos) · Bruno collections
On this page: All related tables · Schema columns · Constraints · DTOs · Validation errors · Delete/archive · Order persistence · Channel visibility · Modifier types · POS flow · REST API
Overview
The Menus screen (/forms/restaurantMenus) lets merchants:
- Create and pin menus to a location (
menu,location_menu_assignment). - Add menu items that reference catalog products (
menu_item). - Configure modifier groups and options per product (
product_modifier_group,product_modifier,product_modifier_group_assignment). - Manage recipes / BOM per product (
product_recipe) on a separate tab.
Modifier groups are business-scoped library entries attached to products via a join table. The same group (e.g. “Tipo de leche”) can be shared across many products.
At POS, selections are validated against effective min/max constraints and persisted on order_item_modifier with immutable snapshots.
PWA route and layout
| Item | Value |
|---|---|
| Route | /forms/restaurantMenus |
| Page component | apps/frontend-pwa/src/components/forms/restaurant/MenusPage.tsx |
| Remote config form key | restaurantMenus (see config/firebase/remote-config/pwaMenu.json) |
Modifier library (business-wide)
| Item | Value |
|---|---|
| Tree editor route | /forms/restaurantModifierGroups |
| Tree editor page | ModifierGroupsLibraryPage.tsx → ModifierGroupsWorkspace (mode: library) |
| Legacy flat list route | /forms/restaurantModifierLibrary (unchanged) |
| Data hook (tree + options) | useModifierLibraryGroupsWithModifiers |
| Shared UI | menus/ModifierGroupFormPanel, ModifierGroupList, appearance editors |
Library mode uses the same tree/options/colors UI as the product tab but does not attach/detach groups to products or reorder product prompt order. Creating a group only calls POST modifier groups (no attachModifierGroup).
Page grid
┌─────────────────────────────────────────────────────────────────┐
│ MenusPageHeader (title, Menu Gateway, Go to Dining, Refresh) │
├──────────────────────┬──────────────────────────────────────────┤
│ MenusPageLeftColumn │ MenusPageContent (right column) │
│ xl:col-span-4 │ xl:col-span-8 2xl:col-span-9 │
│ │ │
│ · location hint │ · MenuRightPanel (when menu selected) │
│ · MenuFormPanel │ - tabs: Menu Items | Modifier Groups │
│ · MenuListPanel │ | Recipes / BOM │
│ · MenuLocationsDialog│ │
└──────────────────────┴──────────────────────────────────────────┘
The right column shell is MenusPageContent (line ~324 in MenusPage.tsx). It only renders MenuRightPanel when selectedMenuId is set.
Component hierarchy (Modifier Groups tab)
flowchart TB
MenusPage --> MenusPageContent
MenusPageContent --> MenuRightPanel
MenuRightPanel --> Tabs[Menu Items | Modifier Groups | Recipes]
MenuRightPanel --> ModifierGroupsTab
ModifierGroupsTab --> ActionBar[ModifierGroupActionBar]
ModifierGroupsTab --> CreateForm[ModifierGroupCreateForm]
ModifierGroupsTab --> List[ModifierGroupList]
ModifierGroupsTab --> LibraryPicker[ModifierGroupLibraryPicker]
List --> Row[ModifierGroupRow]
Row --> Header[ModifierGroupRowHeader]
Row --> Override[ModifierGroupOverrideEditor]
Row --> OptionRow[ModifierOptionRow]
Row --> OptionAdd[ModifierOptionAddForm]
File map (frontend)
| Component | Path | Role |
|---|---|---|
MenusPage | MenusPage.tsx | Page shell, menusPageReducer, data/actions hooks |
MenuRightPanel | menus/MenuRightPanel.tsx | Tab bar; routes to Items / Modifiers / Recipes |
ModifierGroupsTab | menus/ModifierGroupsTab.tsx | Orchestrates bar, form, list, library picker |
useModifierGroupsTab | menus/useModifierGroupsTab.ts | Local reducer + API handlers |
ModifierGroupActionBar | menus/ModifierGroupActionBar.tsx | Create new · Attach from library |
ModifierGroupList | menus/ModifierGroupList.tsx | Maps groups → rows |
ModifierGroupRow | menus/ModifierGroupRow.tsx | Expandable card, options, overrides |
ModifierGroupRowHeader | menus/ModifierGroupRowHeader.tsx | Name, required badge, min–max pill, reorder, detach/delete |
ModifierGroupLibraryPicker | menus/ModifierGroupLibraryPicker.tsx | Modal to attach existing library groups |
useRestaurantModifiers | hooks/restaurant/useRestaurantModifiers.ts | TanStack Query for product groups |
restaurant-modifier.service | services/restaurant/restaurant-modifier.service.ts | Typed API client |
ModifierGroupActionBar
| Button | Behavior |
|---|---|
| Create new | Toggles mode between "list" and "create" in useModifierGroupsTab. Primary variant when create mode is active. Shows ModifierGroupCreateForm below. |
| Attach from library | Opens ModifierGroupLibraryPicker (showLibraryPicker: true). |
Create new calls POST /product-modifier-groups then POST /products/:productId/modifier-groups (create + attach in one flow).
Attach from library calls only POST /products/:productId/modifier-groups.
ModifierGroupRowHeader
Left → right:
- Expand/collapse — chevron; rows default to collapsed.
- Group name —
group.name. - Required badge — when
effectiveMinSelection ?? minSelection >= 1. - Selection range —
{effectiveMin}–{effectiveMax}(e.g.0–1). - Move up / down — swaps
sortOrderviaPATCH /product-modifier-groups/:id. - Detach (
Link2Off) — whenonDetachis provided (product assignment context). Removesproduct_modifier_group_assignmentrow only. - Delete (
Trash2) — when not detachable; deletes/archives the library group.
Expanded body (in ModifierGroupRow):
ModifierGroupOverrideEditor— per-product min/max overrides (PATCH …/modifier-group-assignments/:id).- Active
ModifierOptionRowlist +ModifierOptionAddForm.
Prerequisites on the Modifier Groups tab
ModifierGroupsTab receives productId from selectedProductId in page state. That ID comes from selecting a row on the Menu Items tab (menu_item.product_id). Without a selected product, the tab shows a dashed empty state.
State management
Page-level (menusPageReducer)
| State field | Purpose |
|---|---|
selectedMenuId | Active menu (left column) |
selectedProductId | Product for Modifiers / Recipes tabs |
rightTab | "items" | "modifiers" | "recipes" |
Hooks: useMenusPageData, useMenusPageActions, useMenusBulkAdd.
Query keys (hooks/restaurant/queryKeys.ts):
restaurantKeys.menus(businessId, locationId)restaurantKeys.menuItems(menuId)restaurantKeys.modifierGroups(productId)restaurantKeys.modifierLibrary(businessId, params)restaurantKeys.recipe(productId)
Tab-level (useModifierGroupsTab reducer)
| Field | Purpose |
|---|---|
mode | "list" | "create" |
showLibraryPicker | Library modal visibility |
groupName, minSelection, maxSelection | Create form |
formError, operationError | User-facing errors |
Data: useRestaurantModifiers(productId) — GET /products/:productId/modifier-groups?include=modifiers, staleTime: 5 minutes.
Per-row state lives in ModifierGroupRow’s local reducer (expand, archive/detach confirms, option form, override editor).
Data model (PostgreSQL)
All related tables (index)
Single checklist of every PostgreSQL object that touches modifiers — catalog, orders, pricing, import, and delivery. Normative column detail is in Schema column reference below.
Core catalog (configure in Menus / REST)
| Table | Kysely | Role |
|---|---|---|
product_modifier_group | productModifierGroup | Business-wide modifier group library (min_selection, max_selection, auto_prompt, is_active) |
product_modifier | productModifier | Options inside a group (group_id, price_adjustment, labels, linked_product_id, channel flags, modifier_type) |
product_modifier_group_assignment | productModifierGroupAssignment | Attaches a group to a product_id with optional min/max overrides |
Shared catalog FK: product (sellable PLU), business.
PostgreSQL enum: modifier_type — values modifier, instruction, combo_choice (on product_modifier; snapshotted on order_item_modifier).
Menus admin (screen context — not modifier data itself)
| Table | Role |
|---|---|
menu | Named menu (BEBIDAS, DESAYUNOS, …) |
menu_item | menu_id + product_id — selecting a row drives which product’s modifiers load |
location_menu_assignment | Menus enabled per location |
Orders / POS runtime
| Table | Role |
|---|---|
order_item | Parent sale line |
order_item_modifier | Immutable snapshots of guest choices (name, price delta, labels, channel flags, modifier_type) |
Legacy: order_item.modifiers JSONB may exist on old rows; new flows use order_item_modifier.
Pricing
| Table | Role |
|---|---|
price_list_item | When item_type = 'modifier', references product_modifier_id |
RPApos import (mapping — not modifier catalog rows)
| Table / enum value | Role |
|---|---|
rpapos_id_map | entity_type ∈ plu_option_group, plu_option_item, plu_option_group_assignment → FlowPOS UUIDs |
rpapos_extraction_archive | Raw SOAP rows per import job |
RPApos source tables (tenant DB / SOAP Catalogo_4, not in FlowPOS):
RPApos pTabla | FlowPOS target | Canonical wire columns (tenant-confirmed) |
|---|---|---|
Producto_Grupo | product_modifier_group | Producto_Grupo, Descripcion, Minimo, Maximo |
Producto_Grupo_Detalle | product_modifier | Producto_Grupo_Detalle, Producto_Grupo, Codigo_PLU, Orden |
Producto_Grupo_Relacion | product_modifier_group_assignment | Codigo_PLU / Id_PLU, Producto_Grupo / Id_Grupo |
See RPApos entity mapping and External RPApos API.
Import order: groups → items → product↔group links (items require plu_option_group id_map rows).
Delivery marketplace
| Table | Role |
|---|---|
marketplace_menu_mapping | entity_type ∈ modifier_group, modifier — external menu IDs (e.g. PedidosYa) |
Combos / bundles (separate subsystem)
| Table | Role |
|---|---|
bundle | Combo / mix-and-match catalog definition |
bundle_component | Component lines; may reference products |
bundle_component_group | Choice groups for restaurant combos |
bundle_application | Audit of bundle applied to a document |
product_modifier.modifier_type = combo_choice is a label-only hook; combo runtime uses bundle tables. See Bundles & modifiers (combos).
Recipes tab (same Menus screen, not modifiers)
| Table | Role |
|---|---|
product_recipe | BOM / ingredient lines per product |
Key migrations
| File | Purpose |
|---|---|
2026-02-18t19-00-00.000z-product-modifiers.mjs | product_modifier_group, product_modifier |
2026-05-08t12-00-00-product-modifier-group-assignment.mjs | product_modifier_group_assignment |
2026-03-14t00-04-00-order-item-modifiers.mjs | order_item_modifier |
2026-05-18t10-00-00-modifier-channel-flags.mjs | Channel visibility on product_modifier |
2026-05-18t11-00-00-modifier-type-enum.mjs | modifier_type enum + column |
2026-05-18t12-00-00-order-item-modifier-channel-snapshots.mjs | Channel snapshots on order_item_modifier |
2026-05-18t14-00-00-rpapos-modifier-entities.mjs | rpapos_entity_type extension for import |
Core modifier catalog
| Table | Kysely | Purpose |
|---|---|---|
product_modifier_group | productModifierGroup | Business-wide library group: name, min_selection, max_selection, sort_order, auto_prompt, is_active |
product_modifier | productModifier | Options in a group: group_id, name, price_adjustment, sort_order, is_default, labels, optional linked_product_id |
product_modifier_group_assignment | productModifierGroupAssignment | Links group → product_id with min_selection_override, max_selection_override, sort_order |
Effective constraints (shown in UI):
effective_min = COALESCE(assignment.min_selection_override, group.min_selection)
effective_max = COALESCE(assignment.max_selection_override, group.max_selection)
Required is not a column: effective_min >= 1.
Unique constraint: one assignment per (product_id, modifier_group_id).
Menus (screen context)
| Table | Purpose |
|---|---|
menu | Named menu (BEBIDAS, DESAYUNOS, …), optional time windows |
menu_item | menu_id + product_id, sort, availability, metadata (button colors, 86d, combo) |
location_menu_assignment | Which menus are available at a location |
Runtime / downstream
| Table | Purpose |
|---|---|
order_item_modifier | Immutable snapshots per order line |
order_item | Parent line; validation on add/update |
price_list_item | Optional product_modifier_id when item_type = 'modifier' |
product_recipe | BOM for Recipes tab |
| Bundle tables | Combo lines may reference product_modifier_id |
erDiagram
menu ||--o{ menu_item : contains
menu_item }o--|| product : references
product ||--o{ product_modifier_group_assignment : has
product_modifier_group_assignment }o--|| product_modifier_group : links
product_modifier_group ||--o{ product_modifier : contains
order_item }o--|| product : sold_as
order_item ||--o{ order_item_modifier : snapshots
order_item_modifier }o--o| product_modifier : catalog_ref
End-to-end flow
- Merchant selects menu BEBIDAS → loads
menu_itemrows. - Selecting a menu item sets
productIdfrommenu_item.product_id. - Modifier Groups tab loads assignments + groups + modifiers for that product.
- At POS,
ModifierValidationServiceenforces constraints;order_item_modifierstores choices.
Schema column reference
Normative DDL lives in packages/backend/database/src/migrations/. Kysely table names use camelCase.
product_modifier_group
Business-scoped library entry. Not tied to a product directly (use product_modifier_group_assignment).
| Column | Type | Default | Description |
|---|---|---|---|
id | uuid | gen_random_uuid() | Primary key |
business_id | uuid | — | FK → business.id ON DELETE CASCADE |
name | varchar | — | Display name (e.g. “Tipo de leche”) |
min_selection | integer | 0 | Minimum options guest must/can pick |
max_selection | integer | 1 | Maximum distinct options allowed; 0 with min_selection = 0 = all optional (group not required, no upper cap) |
selection_mode | modifier_group_selection_mode | 'unique' | How picks count toward min/max: unique (distinct options), checkbox (on/off per option; distinct picks like unique), repeatable_bounded (total slot count, same option can repeat), repeatable_unlimited (no max; price × quantity) |
sort_order | integer | 0 | Order in library lists / group reorder |
auto_prompt | boolean | false | If true, force modifier picker open even when all groups are optional |
is_active | boolean | true | false = archived (hidden from active lists) |
created_at | timestamptz | CURRENT_TIMESTAMP | |
created_by | uuid | null | FK → user.id SET NULL |
updated_at | timestamptz | null | |
updated_by | uuid | null | FK → user.id SET NULL |
Derived (not stored): is_required ≡ min_selection >= 1 (or effective min on product endpoints).
product_modifier
Individual option within a group.
| Column | Type | Default | Description |
|---|---|---|---|
id | uuid | gen_random_uuid() | Primary key |
group_id | uuid | — | FK → product_modifier_group.id ON DELETE CASCADE |
name | varchar | — | Option label (e.g. “Deslactosada”) |
price_adjustment | numeric(20,4) | 0 | Added to line price; negative allowed (e.g. “Sin queso −Q0.50”); rendered as signed badge (+Q1.50 / −Q0.50) in the POS modifier dialog; zero renders no badge |
sort_order | integer | 0 | Order within group |
is_default | boolean | false | Pre-select when POS dialog opens for a new order item in unique (and other non-checkbox) modes (ignored when editing an existing line; first default wins for single-select groups with multiple defaults, and a warning toast is shown) |
default_checked | boolean | false | Catalog “starts on” for checkbox selection mode — pre-checks the option when the POS opens for a new item (separate from is_default; not stored on order lines) |
is_active | boolean | true | false = archived |
kitchen_label | varchar(150) | null | KDS override (e.g. XTRA CHEESE, SIN CEBOLLA); admin-editable via ModifierEditor |
receipt_label | varchar(150) | null | Receipt/PDF override; admin-editable via ModifierEditor |
linked_product_id | uuid | null | FK → product.id SET NULL — optional inventory link |
quantity_per_modifier | numeric(20,6) | null | Units of linked product per selection; required if linked_product_id set |
modifier_type | modifier_type | 'modifier' | Semantic type: modifier (paid add-on), instruction (kitchen-only note), combo_choice (label only; combo runtime via bundle subsystem). See Modifier types. |
show_on_kitchen_ticket | boolean | true | Include on kitchen / KDS tickets. Filtered at print and KDS WebSocket render points. |
show_on_receipt | boolean | true | Include on customer receipts and bill PDFs. |
show_on_customer_display | boolean | true | Include on CFD screen (snapshot captured at v1; no CFD consumer yet). |
show_on_online_ordering | boolean | true | Include in online ordering catalog push (PedidosYa menu push filters on this live column, not snapshot). |
created_at, created_by, updated_at, updated_by | Audit |
Note: Stock deduction from linked_product_id at sale finalization is not implemented yet (TODO(modifier-inventory) in migration comments).
product_modifier_group_assignment
Links a library group to a product with optional per-product rules.
| Column | Type | Default | Description |
|---|---|---|---|
id | uuid | gen_random_uuid() | Primary key; API field assignmentId |
product_id | uuid | — | FK → product.id ON DELETE CASCADE |
modifier_group_id | uuid | — | FK → product_modifier_group.id ON DELETE CASCADE |
sort_order | integer | 0 | Order of this group on the product |
min_selection_override | integer | null | NULL → use group min_selection |
max_selection_override | integer | null | NULL → use group max_selection |
created_at, created_by, updated_at, updated_by | Audit |
Unique: (product_id, modifier_group_id) — attach once per product.
Effective constraints (computed in queries):
effective_min = COALESCE(min_selection_override, pmg.min_selection)
effective_max = COALESCE(max_selection_override, pmg.max_selection)
order_item_modifier
Transactional record of one modifier choice on an order line. Snapshot columns are immutable — catalog renames must not alter history.
| Column | Type | Default | Description |
|---|---|---|---|
id | uuid | gen_random_uuid() | Primary key |
order_item_id | uuid | — | FK → order_item.id ON DELETE CASCADE |
product_modifier_group_id | uuid | null | FK → product_modifier_group.id SET NULL |
product_modifier_id | uuid | null | FK → product_modifier.id SET NULL |
quantity | numeric(20,6) | 1 | Units (e.g. 2× extra shot) |
unit_price_adjustment | numeric(20,4) | 0 | Per-unit price delta at order time |
total_price_adjustment | numeric(20,4) | 0 | unit_price_adjustment × quantity |
name_snapshot | varchar(150) | — | Required — modifier name at order time |
kitchen_label_snapshot | varchar(150) | null | Kitchen display string |
group_name_snapshot | varchar(150) | null | Group name at order time — rendered as [Group Name] section headers on kitchen tickets; null modifiers print without a header |
receipt_label_snapshot | varchar(150) | null | Receipt display string |
is_default_snapshot | boolean | false | Was default option at order time |
linked_product_id_snapshot | uuid | null | No FK — survives catalog deletes |
modifier_type_snapshot | modifier_type | null | Type at order time; null for pre-migration rows → treated as 'modifier' |
show_on_kitchen_ticket_snapshot | boolean | null | Channel flag at order time; null treated as true (IS NOT FALSE filter) |
show_on_receipt_snapshot | boolean | null | Channel flag at order time; null treated as true |
show_on_customer_display_snapshot | boolean | null | Channel flag at order time; null treated as true |
show_on_online_ordering_snapshot | boolean | null | Channel flag at order time; null treated as true |
created_at | timestamptz | CURRENT_TIMESTAMP | |
created_by | uuid | null | FK → user.id SET NULL |
Legacy order_item.modifiers JSONB may still exist on older rows; new flows use this table.
Constraints and indexes
Check constraints
| Table | Constraint | Rule |
|---|---|---|
product_modifier_group | chk_modifier_group_selection_range | min_selection >= 0, max_selection >= 0, min_selection <= max_selection |
product_modifier | chk_modifier_quantity_positive | quantity_per_modifier IS NULL OR quantity_per_modifier > 0 |
product_modifier | chk_modifier_inventory_link_consistency | (linked_product_id IS NULL) = (quantity_per_modifier IS NULL) |
product_modifier_group_assignment | chk_assignment_override_range | Overrides non-negative; if both set, min <= max |
Indexes
| Index | Table | Columns | Purpose |
|---|---|---|---|
idx_product_modifier_group_business | product_modifier_group | business_id | Library list by business |
idx_product_modifier_group_id | product_modifier | group_id | Options by group |
idx_product_modifier_linked_product | product_modifier | linked_product_id (partial, WHERE NOT NULL) | Inventory-linked modifiers |
idx_assignment_product_id | product_modifier_group_assignment | product_id | Groups for product |
idx_assignment_modifier_group_id | product_modifier_group_assignment | modifier_group_id | Products using a group |
idx_order_item_modifier_order_item_id | order_item_modifier | order_item_id | Modifiers on a line |
idx_order_item_modifier_product_modifier_id | order_item_modifier | product_modifier_id | Modifier usage analytics |
API request bodies (DTOs)
Backend DTOs: apps/backend/src/restaurant/interfaces/dtos/. PWA Zod: apps/frontend-pwa/src/schemas/restaurant/modifier-group.schema.ts.
Create modifier group — POST /product-modifier-groups
| Field | Type | Required | Notes |
|---|---|---|---|
businessId | uuid | yes | |
name | string | yes | |
minSelection | int 0–100 | no | default 0 |
maxSelection | int 0–100 | no | default 1; must be ≥ min |
sortOrder | int ≥ 0 | no | |
autoPrompt | boolean | no | Force picker on POS |
Attach to product — POST /products/:productId/modifier-groups
| Field | Type | Required | Notes |
|---|---|---|---|
modifierGroupId | uuid | yes | Library group to link |
sortOrder | int ≥ 0 | no | Assignment order |
minSelectionOverride | int 0–100 | no | null = use group base |
maxSelectionOverride | int 0–100 | no | null = use group base |
Update assignment — PATCH /products/:productId/modifier-group-assignments/:assignmentId
Same override fields as attach (sortOrder, minSelectionOverride, maxSelectionOverride).
Create modifier option — POST /product-modifiers
| Field | Type | Required | Notes |
|---|---|---|---|
groupId | uuid | yes | Parent group |
name | string | yes | |
priceAdjustment | number | no | Negative allowed on API/DB |
sortOrder | int ≥ 0 | no | |
isDefault | boolean | no | Pre-select on dialog |
kitchenLabel | string ≤150 | no | KDS override; null = use name |
receiptLabel | string ≤150 | no | Receipt override; null = use name |
linkedProductId | uuid | no | Must pair with quantityPerModifier |
quantityPerModifier | number > 0 | no | Required when link set |
modifierType | modifier | instruction | combo_choice | no | Default modifier |
showOnKitchenTicket | boolean | no | Default true |
showOnReceipt | boolean | no | Default true |
showOnCustomerDisplay | boolean | no | Default true |
showOnOnlineOrdering | boolean | no | Default true |
Update modifier option — PATCH /product-modifiers/:id
All fields optional (sparse update). Same field set as create except groupId is not updatable. Channel flags and modifierType omitted from the payload when not explicitly provided (not reset to defaults).
| Field | Notes |
|---|---|
name, priceAdjustment, sortOrder, isDefault | Core fields |
kitchenLabel, receiptLabel | Send null to clear |
linkedProductId, quantityPerModifier | Must be updated together |
modifierType | modifier | instruction | combo_choice |
showOnKitchenTicket, showOnReceipt, showOnCustomerDisplay, showOnOnlineOrdering | Boolean channel flags |
POS / order — selection payload
Used in add-item and replace-modifiers requests:
interface SelectedModifierItem {
modifierId: string;
groupId: string;
quantity?: number; // default 1
}
PATCH /orders/:orderId/items/:itemId/modifiers body: { selectedModifiers: SelectedModifierItem[] }.
Validation and error codes
ModifierValidationService (pure, no DB)
Service: apps/backend/src/restaurant/application/modifier-validation.service.ts.
Input: selected[] with { modifierId, groupId, quantity? } and groups[] with effective constraints plus selectionMode per attached group.
Selection modes (product_modifier_group.selection_mode, enum modifier_group_selection_mode):
| Mode | POS behavior | Min/max applies to |
|---|---|---|
unique (default) | Tap toggles each option; radio UI when max = 1 | Distinct options selected |
checkbox | Check/uncheck each option; checkbox UI even when max = 1 (swap pick, not radiogroup) | Distinct options selected |
repeatable_bounded | Each tap adds a slot; same option can repeat | Total slot count (sum of quantities) |
repeatable_unlimited | Each tap adds; max ignored | Total slot count; min still enforced |
Pricing: extra = Σ (priceAdjustment × quantity); persisted rows use collapsed { modifierId, groupId, quantity }.
| Code | Condition | Message pattern |
|---|---|---|
REQUIRED_GROUP_MISSING | isRequired and zero slots in group | Selection required for "{groupName}" |
MIN_SELECTIONS_NOT_MET | slot count < minSelections | At least N selection(s) required… |
MAX_SELECTIONS_EXCEEDED | slot count > maxSelections (bounded modes when max > 0) | At most N selection(s) allowed… |
DUPLICATE_MODIFIER_IN_GROUP | unique / checkbox mode and same modifierId twice | Duplicate option is not allowed… |
INVALID_QUANTITY_FOR_UNIQUE | unique / checkbox mode and quantity > 1 | Only one of each option is allowed… |
isRequired on each constraint = effectiveMinSelection >= 1.
HTTP: 422 Unprocessable Entity with body:
{
"message": "Modifier validation failed.",
"code": "MODIFIER_VALIDATION_FAILED",
"errors": [ { "groupId", "groupName", "code", "message", "actualSelections", ... } ]
}
Order edit lock
| Code | HTTP | When |
|---|---|---|
ORDER_ITEM_LOCKED_FOR_EDIT | 409 | PATCH …/modifiers when order_item.status !== "pending" (already sent to kitchen) |
Delete / routing conflicts
| Code | HTTP | Entity | When |
|---|---|---|---|
MODIFIER_GROUP_IN_ROUTING | 409 | Group | Any option in group appears in product_station_assignment.modifier_routing |
MODIFIER_IN_ROUTING | 409 | Option | Modifier id keyed in modifier_routing JSONB |
Routing check uses PostgreSQL ?| (any key exists) for groups and ? for single modifier id.
Delete and archive behavior
Implemented in ProductModifierGroupService.deleteModifierGroup and ProductModifierService.deleteModifier.
Modifier group delete (DELETE /product-modifier-groups/:id)
flowchart TD
A[DELETE group] --> B{Any option in modifier_routing?}
B -->|yes| C[409 MODIFIER_GROUP_IN_ROUTING]
B -->|no| D{order_item_modifier rows for group?}
D -->|yes| E[Archive: is_active = false + warning]
D -->|no| F[Hard delete group + cascade options]
Modifier option delete (DELETE /product-modifiers/:id)
Same three paths; routing code MODIFIER_IN_ROUTING (single modifier).
Detach (DELETE /products/:productId/modifier-groups/:groupId)
- Deletes only
product_modifier_group_assignmentrow. - Library group and options remain; other products keep the attachment.
- UI shows Detach when
onDetachis wired (product-scoped list).
Inactive group delete in admin UI
If group is already archived (is_active = false), delete click shows ModifierGroupArchiveConfirm before calling delete API again.
Order persistence (POS)
Service: OrderItemModifierPersistenceService
Path: apps/backend/src/restaurant/application/order-item-modifier-persistence.service.ts
Always called inside a caller-owned transaction (trx).
persist(trx, input)
Used when adding an order item with modifiers (OrdersService).
- If
selectedModifiersempty → no-op. - Unless
skipGroupValidation: true:- Load effective groups via
findActiveGroupsWithEffectiveConstraints(productId). - Run
ModifierValidationService.validate. - On failure →
422 MODIFIER_VALIDATION_FAILED.
- Load effective groups via
- Load catalog rows (modifier + group name) for all
modifierIds. - Build
order_item_modifierrows with snapshots and price math (Decimal.js). - Bulk insert.
replace(trx, input)
Used by PATCH /orders/:id/items/:itemId/modifiers.
- Load
order_item; if missing → return. - If
status !== "pending"→409 ORDER_ITEM_LOCKED_FOR_EDIT. DELETEexistingorder_item_modifierfor thatorder_item_id.- If new selections non-empty → same insert path as
persist.
skipGroupValidation
Set true when BundlesService persists combo-driven selections already validated against bundle rules (not product modifier groups). Snapshots are still built from the catalog.
Snapshot fields written at insert
| Snapshot column | Source |
|---|---|
name_snapshot | product_modifier.name |
kitchen_label_snapshot | kitchen_label |
receipt_label_snapshot | receipt_label |
group_name_snapshot | parent group name |
is_default_snapshot | is_default |
linked_product_id_snapshot | linked_product_id |
modifier_type_snapshot | modifier_type |
show_on_kitchen_ticket_snapshot | show_on_kitchen_ticket |
show_on_receipt_snapshot | show_on_receipt |
show_on_customer_display_snapshot | show_on_customer_display |
show_on_online_ordering_snapshot | show_on_online_ordering |
unit_price_adjustment / total_price_adjustment | price_adjustment × quantity |
Channel visibility flags
Each product_modifier carries four boolean columns controlling which output channels render the option. All default to true (fully backward-compatible). Values are captured as immutable snapshots at order time so historical orders render correctly even if catalog flags are later changed.
| Flag | Catalog column | Snapshot column | Honored by | Default |
|---|---|---|---|---|
| Kitchen ticket | show_on_kitchen_ticket | show_on_kitchen_ticket_snapshot | print-job.repository.ts (KDS print) · orders.service.ts (KDS WebSocket — 2 call sites) | true |
| Receipt / bill | show_on_receipt | show_on_receipt_snapshot | document-print-payload.repository.ts (loadModifierLabels) | true |
| Customer display | show_on_customer_display | show_on_customer_display_snapshot | Snapshot captured at v1 — no CFD consumer yet | true |
| Online ordering | show_on_online_ordering | show_on_online_ordering_snapshot¹ | pedidosya-menu.mapper.ts | true |
¹ The PedidosYa menu push is a live catalog push, not a per-order render — it reads the live show_on_online_ordering column directly (not the snapshot). All other channels filter on the snapshot column.
Null-safe filter pattern used at every channel render point:
WHERE show_on_kitchen_ticket_snapshot IS NOT FALSE
IS NOT FALSE passes both TRUE and NULL, so pre-migration rows (null snapshots) are rendered as visible — preserving existing behavior.
ModifierDialog (POS) must NOT filter. Cashiers need all options when building an order regardless of channel flags. Filtering applies only to output/rendering surfaces.
Modifier types
The modifier_type column (PostgreSQL enum modifier_type) classifies each option's semantic role. Snapshotted as modifier_type_snapshot on order_item_modifier.
| Value | Admin label | Meaning |
|---|---|---|
modifier | Modifier (paid add-on) | Default. Any option that adds or removes price. Charged to the guest. |
instruction | Instruction (kitchen note) | Free kitchen instruction with no price (e.g. "Sin cebolla"). Typically price_adjustment = 0, show_on_receipt = false. Displayed with a small "note" badge in the admin option list. |
combo_choice | Combo choice | Label only at v1. Combo runtime semantics (bundled items, per-component pricing) live in the bundle subsystem (bundle_component table). See Bundles & modifiers. |
Admin UI: ModifierEditor (inline expand panel within ModifierGroupRow) exposes a Select for modifierType, four channel visibility switches, and the kitchenLabel / receiptLabel text inputs.
Null-safety: modifier_type_snapshot IS NULL on pre-migration rows → treated as 'modifier' at all render points.
POS flow
sequenceDiagram
participant Waiter as POS PWA
participant API as Backend
participant DB as PostgreSQL
Waiter->>API: GET /products/:productId/modifier-groups?include=modifiers
API->>DB: assignment JOIN group + active modifiers
DB-->>API: effective min/max per group
API-->>Waiter: groups + options
Note over Waiter: Modifier dialog (one level deep in V1)
Waiter->>API: POST /orders/:orderId/items (+ selectedModifiers)
API->>API: ModifierValidationService
alt invalid
API-->>Waiter: 422 MODIFIER_VALIDATION_FAILED
else valid
API->>DB: INSERT order_item + order_item_modifier snapshots
API-->>Waiter: order item with modifiers
end
opt Edit before send to kitchen
Waiter->>API: PATCH /orders/:orderId/items/:itemId/modifiers
API->>DB: DELETE + re-INSERT snapshots (status must be pending)
end
Load rules for POS lists:
- Only groups where
product_modifier_group.is_active = trueand assignment exists for product. - Only modifiers where
product_modifier.is_active = true. - Ordering: assignment
sort_order, then groupcreated_at; modifiers bysort_order.
Default pre-selection:
When the modifier dialog opens for a new item (initialSelections is absent), ModifierDialog seeds each group from catalog flags:
unique(and repeatable modes): options withis_default = true.checkbox: options withdefault_checked = true(admin Starts on control; star /is_defaulthidden in Menus for checkbox groups).- Single-select group (
maxSelection = 1) with multiple catalog defaults → first default wins, warning toast shown. - When editing an existing item (
initialSelectionsis provided), catalog defaults are not applied — saved selections are honored verbatim. Order lines store only actual picks (order_item_modifier), notdefault_checked.
REST API
Base URL: API root (e.g. http://localhost:4000). Auth: Firebase ID token (AuthGuard). Swagger tag: Restaurant Product Modifiers / Restaurant Menus.
Modifier groups tab (direct)
| Method | Path | PWA usage |
|---|---|---|
GET | /products/:productId/modifier-groups?include=modifiers | Load groups + options (single request) |
POST | /products/:productId/modifier-groups | Attach / attach after create |
DELETE | /products/:productId/modifier-groups/:groupId | Detach |
PATCH | /products/:productId/modifier-group-assignments/:assignmentId | Per-product min/max overrides |
GET | /product-modifier-groups?businessId=&search=&page=&pageSize= | Library picker |
POST | /product-modifier-groups | Create library group |
GET | /product-modifier-groups/:id | Get group |
PATCH | /product-modifier-groups/:id | Update group (incl. sortOrder reorder) |
DELETE | /product-modifier-groups/:id | Delete or archive group |
GET | /product-modifier-groups/:groupId/modifiers | List options (legacy; prefer include=modifiers) |
POST | /product-modifiers | Create option |
GET | /product-modifiers/:id | Get option |
PATCH | /product-modifiers/:id | Update / reorder option |
DELETE | /product-modifiers/:id | Delete or archive option |
Menus page (left column + Menu Items tab)
| Method | Path | Purpose |
|---|---|---|
POST | /menus | Create menu |
GET | /menus | List menus |
GET | /menus/:id | Get menu |
PATCH | /menus/:id | Update menu |
DELETE | /menus/:id | Delete menu |
GET | /menus/:menuId/locations?businessId= | Location assignment matrix |
POST | /menus/:menuId/items | Create menu item |
GET | /menus/:menuId/items | List items (pagination, available_at) |
GET | /menus/:menuId/items/:itemId | Get item |
PATCH | /menus/:menuId/items/:itemId | Update item |
DELETE | /menus/:menuId/items/:itemId | Delete item |
POST | /locations/:locationId/menus | Pin menu to location |
DELETE | /locations/:locationId/menus/:menuId | Unpin |
GET | /locations/:locationId/menus | Menus for location |
Orders (POS — same catalog)
| Method | Path | Purpose |
|---|---|---|
PATCH | /orders/:id/items/:itemId/modifiers | Replace modifiers on a line |
| Order create/update | via OrdersService | Validates selections via ModifierValidationService |
Controller: apps/backend/src/restaurant/interfaces/product-modifiers.controller.ts
Menus: apps/backend/src/restaurant/interfaces/menus.controller.ts
Kitchen ticket modifier rendering
Kitchen tickets produced by buildGroupedKitchenLines / buildSingleKitchenLines (packages/receipt-layout) now render modifier groups using the structured KitchenModifierLine[] type:
2x Hamburguesa Clásica
[Cooking Temp]
- Rare
[Extras]
- Cheese
- ×2 Bacon
- Group headers appear when consecutive modifiers share a non-null
group_name_snapshot. Modifiers withnullgroup name print without a header. - Quantity prefix (
×N) is shown whenorder_item_modifier.quantity > 1. mapKitchenModifierLines(rows)(exported from@flowpos-workspace/receipt-layout) convertsorder_item_modifierDB rows toKitchenModifierLine[]. Bothprint-job.repository.ts(KDS) anddocument-print-payload.repository.tsuse this helper.- The old
string[]path in the layout functions is retained for backward compatibility with the Print Bridge agent (which builds its own modifier list outside this package).
Backend module
apps/backend/src/restaurant/
├── modules/menu.module.ts
│ ├── MenusController
│ └── ProductModifiersController
├── application/
│ ├── menu.service.ts
│ ├── product-modifier-group.service.ts
│ ├── product-modifier.service.ts
│ ├── product-modifier-group-assignment.service.ts
│ ├── modifier-validation.service.ts # pure; no DB
│ └── order-item-modifier-persistence.service.ts
└── infrastructure/
├── menu.repository.ts
├── menu-item.repository.ts
├── product-modifier-group.repository.ts
├── product-modifier.repository.ts
└── product-modifier-group-assignment.repository.ts
listActiveGroupsWithEffectiveConstraints joins product_modifier_group_assignment → product_modifier_group and computes effective min/max in SQL.
See Delete and archive behavior, Order persistence (POS), and Validation and error codes for full detail.
Authorization (CASL)
| Resource | Typical actions on Menus UI |
|---|---|
ModifierGroup | CRUD on groups and options |
ModifierGroupAssignment | Attach, list, detach, patch overrides |
Enums: packages/global/policies/src/enums/resource.enums.ts
Rules: packages/global/policies/src/rules/business.rules.ts
Related features
| Area | Connection |
|---|---|
Order taking (restaurantOrders) | Modifier picker dialog; validation; order_item_modifier persistence |
| Pricing | price_list_item.product_modifier_id for modifier-level prices |
| Bundles / combos | Pre-selected modifiers on combo lines |
| KDS / kitchen | kitchen_label_snapshot, receipt_label_snapshot; modifiers grouped by group_name_snapshot on kitchen tickets (rendered as [Group Name] headers) |
| Recipes / BOM | Same productId; product_recipe table; separate tab in MenuRightPanel |
| Delivery marketplaces | Menu push mappers include modifier structure |
See also: document line item shape, restaurant combos.
Migrations
| Migration file | What it adds |
|---|---|
2026-02-18t19:00:00.000z-product-modifiers.mjs | product_modifier_group, product_modifier |
2026-02-18t23:00:00.000z-menu-management.mjs | menu, menu_item, location_menu_assignment |
2026-04-02t07-00-00-modifier-is-active.mjs | is_active on group + modifier |
2026-03-14t00-04-00-order-item-modifiers.mjs | order_item_modifier |
2026-05-08t12-00-00-product-modifier-group-assignment.mjs | Assignment join table |
2026-05-08t15-00-00-modifier-schema-backfill.mjs | Backfill from old product_id on groups |
2026-04-02t03-00-00-product-recipe.mjs | product_recipe (Recipes tab) |
2026-05-18t10-00-00-modifier-channel-flags.mjs | show_on_kitchen_ticket, show_on_receipt, show_on_customer_display, show_on_online_ordering on product_modifier (boolean, DEFAULT true) |
2026-05-18t11-00-00-modifier-type-enum.mjs | modifier_type PostgreSQL enum + modifier_type column on product_modifier (DEFAULT 'modifier') |
2026-05-18t12-00-00-order-item-modifier-channel-snapshots.mjs | Five snapshot columns on order_item_modifier (nullable, DEFAULT null) |
After schema changes: pnpm run generate:types.
TypeScript types (PWA)
apps/frontend-pwa/src/types/restaurant-modifier.ts:
ProductModifierGroup,ProductModifierGroupWithModifiers,ProductModifierModifierType—"modifier" | "instruction" | "combo_choice"type aliasUpdateModifierPayload— sparse update payload; all fields optional includingmodifierTypeand the fourshowOn*booleansisGroupRequired(group)→(effectiveMinSelection ?? minSelection) >= 1- Payload types for attach, create, update assignment
Zod schemas: apps/frontend-pwa/src/schemas/restaurant/modifier-group.schema.ts
i18n keys (modifier UI)
| Key | Default (en) |
|---|---|
restaurant.menus.tabModifiers | Modifier Groups |
restaurant.menus.modifiers.createNew | Create new |
restaurant.menus.modifiers.attachFromLibrary | Attach from library |
restaurant.menus.modifiers.required | Required |
restaurant.menus.modifiers.detach | Detach |
restaurant.menus.selectItemForModifiers | Select a menu item to manage its modifier groups. |
restaurant.menus.noModifierGroups | No modifier groups yet. |
restaurant.menus.modifiers.kitchenLabel | Kitchen label |
restaurant.menus.modifiers.receiptLabel | Receipt label |
restaurant.menus.modifiers.modifierType | Modifier type |
restaurant.menus.modifiers.typeModifier | Modifier (paid add-on) |
restaurant.menus.modifiers.typeInstruction | Instruction (kitchen note) |
restaurant.menus.modifiers.typeComboChoice | Combo choice |
restaurant.menus.modifiers.typeInstructionBadge | note |
restaurant.menus.modifiers.channelVisibility | Channel visibility |
restaurant.menus.modifiers.showOnKitchenTicket | Show on kitchen ticket |
restaurant.menus.modifiers.showOnReceipt | Show on receipt |
restaurant.menus.modifiers.showOnCustomerDisplay | Show on customer display |
restaurant.menus.modifiers.showOnOnlineOrdering | Show on online ordering |
restaurant.menus.modifiers.editOption | Edit option details |
restaurant.menus.modifiers.updateModifierError | Failed to update option. |
Testing
| Layer | Location | Covers |
|---|---|---|
| PWA | menus/__tests__/ModifierGroupsTab.test.tsx | Tab orchestration, create/delete/reorder |
| PWA | menus/__tests__/ModifierGroupRow.test.tsx | Expand, archive confirm, option list |
| PWA | menus/__tests__/ModifierEditor.test.tsx | Channel switches, modifier type select, label nullification, save/cancel |
| Backend | application/__tests__/modifier-validation.service.spec.ts | Constraint validation |
| Backend | application/__tests__/product-modifier-channel-flags.service.spec.ts | modifierType + showOn* round-trip create/update; sparse update pattern |
| Backend | infrastructure/pedidosya/mappers/pedidosya-menu.mapper.spec.ts | showOnOnlineOrdering=false filtered from menu push; undefined backward-compat |
pnpm --filter frontend-pwa run test -- ModifierGroups
pnpm --filter frontend-pwa run test -- ModifierEditor
pnpm --filter backend run test -- modifier
UX notes
- Detach vs delete: On the product tab, attached groups show Detach (unlink only). Delete/archive applies to the library entity and may be blocked by order history or KDS routing.
- Archive confirm: Inactive groups with history show
ModifierGroupArchiveConfirmbefore delete. - Library sharing: Same group ID can attach to many products; overrides are per assignment.
- POS dialog: V1 supports one level of modifier groups only (see feature-restaurant-pwa).