// Save menu data from the builder UI (OPTIMIZED) // Input: BusinessID, Menu (JSON structure) // Output: { OK: true } // VERSION: 2026-02-08-fix1 response = { "OK": false, "VERSION": "2026-02-08-fix2", "DEBUG": [] }; // Track which templates we've already saved options for (to avoid duplicate saves) savedTemplates = {}; // Recursive function to save options/modifiers at any depth function saveOptionsRecursive(options, parentID, businessID) { if (!isArray(options) || arrayLen(options) == 0) return; var optSortOrder = 0; for (var opt in options) { var optDbId = structKeyExists(opt, "dbId") ? val(opt.dbId) : 0; var requiresSelection = (structKeyExists(opt, "requiresSelection") && opt.requiresSelection) ? 1 : 0; var maxSelections = structKeyExists(opt, "maxSelections") ? val(opt.maxSelections) : 0; var isDefault = (structKeyExists(opt, "isDefault") && opt.isDefault) ? 1 : 0; var isInverted = (structKeyExists(opt, "isInverted") && opt.isInverted) ? 1 : 0; var optionID = 0; if (optDbId > 0) { optionID = optDbId; queryTimed(" UPDATE Items SET Name = :name, Price = :price, IsCheckedByDefault = :isDefault, SortOrder = :sortOrder, RequiresChildSelection = :requiresSelection, MaxNumSelectionReq = :maxSelections, IsInvertedGroup = :isInverted, ParentItemID = :parentID WHERE ID = :optID ", { optID: optDbId, parentID: parentID, name: opt.name, price: val(opt.price ?: 0), isDefault: isDefault, sortOrder: optSortOrder, requiresSelection: requiresSelection, maxSelections: maxSelections, isInverted: isInverted }); } else { queryTimed(" INSERT INTO Items ( BusinessID, ParentItemID, Name, Price, IsCheckedByDefault, SortOrder, IsActive, AddedOn, RequiresChildSelection, MaxNumSelectionReq, IsInvertedGroup, CategoryID ) VALUES ( :businessID, :parentID, :name, :price, :isDefault, :sortOrder, 1, NOW(), :requiresSelection, :maxSelections, :isInverted, 0 ) ", { businessID: businessID, parentID: parentID, name: opt.name, price: val(opt.price ?: 0), isDefault: isDefault, sortOrder: optSortOrder, requiresSelection: requiresSelection, maxSelections: maxSelections, isInverted: isInverted }); var result = queryTimed("SELECT LAST_INSERT_ID() as newID"); optionID = result.newID; } if (structKeyExists(opt, "options") && isArray(opt.options) && arrayLen(opt.options) > 0) { saveOptionsRecursive(opt.options, optionID, businessID); } optSortOrder++; } } try { requestBody = toString(getHttpRequestData().content); if (!len(requestBody)) { throw("Request body is required"); } jsonData = deserializeJSON(requestBody); businessID = val(jsonData.BusinessID ?: 0); menu = jsonData.Menu ?: {}; if (businessID == 0) { throw("BusinessID is required"); } if (!structKeyExists(menu, "categories") || !isArray(menu.categories)) { throw("Menu categories are required"); } // Check if Categories table has data (must match getForBuilder logic!) // If Categories table has data, use legacy schema (CategoryID in Items) // Otherwise use unified schema (ParentItemID in Items) hasCategoriesData = false; try { qCatCheck = queryTimed(" SELECT 1 FROM Categories WHERE BusinessID = :businessID LIMIT 1 ", { businessID: businessID }, { datasource: "payfrit" }); hasCategoriesData = (qCatCheck.recordCount > 0); } catch (any e) { hasCategoriesData = false; } newSchemaActive = !hasCategoriesData; // Wrap everything in a transaction for speed and consistency transaction { // Track JS category id -> DB categoryID mapping for resolving subcategory parents jsCatIdToDbId = {}; catSortOrder = 0; for (cat in menu.categories) { categoryID = 0; categoryDbId = structKeyExists(cat, "dbId") ? val(cat.dbId) : 0; catItemCount = structKeyExists(cat, "items") && isArray(cat.items) ? arrayLen(cat.items) : 0; // Debug: log each category being processed arrayAppend(response.DEBUG, "Category: " & cat.name & " (dbId=" & categoryDbId & ") with " & catItemCount & " items"); // Initialize menuId param - use 0 for "no menu" (nullable in DB) categoryMenuId = structKeyExists(cat, "menuId") ? val(cat.menuId) : 0; // Resolve parentCategoryID: first try dbId, then look up from JS id mapping parentCategoryID = 0; if (structKeyExists(cat, "parentCategoryDbId") && val(cat.parentCategoryDbId) > 0) { parentCategoryID = val(cat.parentCategoryDbId); } else if (structKeyExists(cat, "parentCategoryId") && len(trim(cat.parentCategoryId)) && cat.parentCategoryId != "0") { // JS category id - look up from mapping if (structKeyExists(jsCatIdToDbId, cat.parentCategoryId)) { parentCategoryID = jsCatIdToDbId[cat.parentCategoryId]; } } if (newSchemaActive) { if (categoryDbId > 0) { categoryID = categoryDbId; queryTimed(" UPDATE Items SET Name = :name, SortOrder = :sortOrder WHERE ID = :categoryID AND BusinessID = :businessID ", { categoryID: categoryID, businessID: businessID, name: cat.name, sortOrder: catSortOrder }); } else { queryTimed(" INSERT INTO Items ( BusinessID, Name, Description, ParentItemID, Price, IsActive, SortOrder, AddedOn, CategoryID ) VALUES ( :businessID, :name, '', 0, 0, 1, :sortOrder, NOW(), 0 ) ", { businessID: businessID, name: cat.name, sortOrder: catSortOrder }); result = queryTimed("SELECT LAST_INSERT_ID() as newID"); categoryID = result.newID; } } else { if (categoryDbId > 0) { categoryID = categoryDbId; queryTimed(" UPDATE Categories SET Name = :name, SortOrder = :sortOrder, MenuID = NULLIF(:menuId, 0), ParentCategoryID = :parentCatId WHERE ID = :categoryID ", { categoryID: categoryID, name: cat.name, sortOrder: catSortOrder, menuId: categoryMenuId, parentCatId: parentCategoryID }); } else { queryTimed(" INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder, ParentCategoryID, AddedOn) VALUES (:businessID, NULLIF(:menuId, 0), :name, :sortOrder, :parentCatId, NOW()) ", { businessID: businessID, menuId: categoryMenuId, name: cat.name, sortOrder: catSortOrder, parentCatId: parentCategoryID }); result = queryTimed("SELECT LAST_INSERT_ID() as newID"); categoryID = result.newID; } } // Track JS category id -> DB id for subcategory parent resolution if (structKeyExists(cat, "id") && len(trim(cat.id))) { jsCatIdToDbId[cat.id] = categoryID; } // Debug: log final categoryID for this category arrayAppend(response.DEBUG, " -> CategoryID resolved to: " & categoryID & " (parentCatID: " & parentCategoryID & ")"); // Process items if (structKeyExists(cat, "items") && isArray(cat.items)) { itemSortOrder = 0; for (item in cat.items) { itemID = 0; itemDbId = structKeyExists(item, "dbId") ? val(item.dbId) : 0; // Debug: log each item being processed arrayAppend(response.DEBUG, " Item: " & item.name & " (dbId=" & itemDbId & ") -> CategoryID=" & categoryID); if (itemDbId > 0) { itemID = itemDbId; if (newSchemaActive) { queryTimed(" UPDATE Items SET Name = :name, Description = :description, Price = :price, ParentItemID = :categoryID, SortOrder = :sortOrder WHERE ID = :itemID ", { itemID: itemID, name: item.name, description: item.description ?: "", price: val(item.price ?: 0), categoryID: categoryID, sortOrder: itemSortOrder }); } else { queryTimed(" UPDATE Items SET Name = :name, Description = :description, Price = :price, CategoryID = :categoryID, SortOrder = :sortOrder WHERE ID = :itemID ", { itemID: itemID, name: item.name, description: item.description ?: "", price: val(item.price ?: 0), categoryID: categoryID, sortOrder: itemSortOrder }); } } else { if (newSchemaActive) { queryTimed(" INSERT INTO Items ( BusinessID, ParentItemID, Name, Description, Price, SortOrder, IsActive, AddedOn, CategoryID ) VALUES ( :businessID, :categoryID, :name, :description, :price, :sortOrder, 1, NOW(), 0 ) ", { businessID: businessID, categoryID: categoryID, name: item.name, description: item.description ?: "", price: val(item.price ?: 0), sortOrder: itemSortOrder }); } else { queryTimed(" INSERT INTO Items ( BusinessID, CategoryID, Name, Description, Price, SortOrder, IsActive, ParentItemID, AddedOn ) VALUES ( :businessID, :categoryID, :name, :description, :price, :sortOrder, 1, 0, NOW() ) ", { businessID: businessID, categoryID: categoryID, name: item.name, description: item.description ?: "", price: val(item.price ?: 0), sortOrder: itemSortOrder }); } result = queryTimed("SELECT LAST_INSERT_ID() as newID"); itemID = result.newID; } // Handle modifiers if (structKeyExists(item, "modifiers") && isArray(item.modifiers) && arrayLen(item.modifiers) > 0) { // Clear existing template links for this item queryTimed("DELETE FROM lt_ItemID_TemplateItemID WHERE ItemID = :itemID", { itemID: itemID }); modSortOrder = 0; for (mod in item.modifiers) { modDbId = structKeyExists(mod, "dbId") ? val(mod.dbId) : 0; requiresSelection = (structKeyExists(mod, "requiresSelection") && mod.requiresSelection) ? 1 : 0; maxSelections = structKeyExists(mod, "maxSelections") ? val(mod.maxSelections) : 0; if (structKeyExists(mod, "isTemplate") && mod.isTemplate && modDbId > 0) { // This is a template reference - create link queryTimed(" INSERT INTO lt_ItemID_TemplateItemID (ItemID, TemplateItemID, SortOrder) VALUES (:itemID, :templateID, :sortOrder) ON DUPLICATE KEY UPDATE SortOrder = :sortOrder ", { itemID: itemID, templateID: modDbId, sortOrder: modSortOrder }); // Update template's selection rules queryTimed(" UPDATE Items SET RequiresChildSelection = :requiresSelection, MaxNumSelectionReq = :maxSelections, IsInvertedGroup = :isInverted WHERE ID = :modID ", { modID: modDbId, requiresSelection: requiresSelection, maxSelections: maxSelections, isInverted: (structKeyExists(mod, "isInverted") && mod.isInverted) ? 1 : 0 }); // Only save template options ONCE (first time we encounter this template) if (!structKeyExists(savedTemplates, modDbId)) { savedTemplates[modDbId] = true; if (structKeyExists(mod, "options") && isArray(mod.options)) { saveOptionsRecursive(mod.options, modDbId, businessID); } } } else if (modDbId > 0) { // Direct modifier (not a template) - update it queryTimed(" UPDATE Items SET Name = :name, Price = :price, IsCheckedByDefault = :isDefault, SortOrder = :sortOrder, RequiresChildSelection = :requiresSelection, MaxNumSelectionReq = :maxSelections, IsInvertedGroup = :isInverted, ParentItemID = :parentID WHERE ID = :modID ", { modID: modDbId, parentID: itemID, name: mod.name, price: val(mod.price ?: 0), isDefault: (mod.isDefault ?: false) ? 1 : 0, sortOrder: modSortOrder, requiresSelection: requiresSelection, maxSelections: maxSelections, isInverted: (structKeyExists(mod, "isInverted") && mod.isInverted) ? 1 : 0 }); if (structKeyExists(mod, "options") && isArray(mod.options)) { saveOptionsRecursive(mod.options, modDbId, businessID); } } else { // New direct modifier - insert it queryTimed(" INSERT INTO Items ( BusinessID, ParentItemID, Name, Price, IsCheckedByDefault, SortOrder, IsActive, AddedOn, RequiresChildSelection, MaxNumSelectionReq, IsInvertedGroup, CategoryID ) VALUES ( :businessID, :parentID, :name, :price, :isDefault, :sortOrder, 1, NOW(), :requiresSelection, :maxSelections, :isInverted, 0 ) ", { businessID: businessID, parentID: itemID, name: mod.name, price: val(mod.price ?: 0), isDefault: (mod.isDefault ?: false) ? 1 : 0, sortOrder: modSortOrder, requiresSelection: requiresSelection, maxSelections: maxSelections, isInverted: (structKeyExists(mod, "isInverted") && mod.isInverted) ? 1 : 0 }); modResult = queryTimed("SELECT LAST_INSERT_ID() as newModID"); newModID = modResult.newModID; if (structKeyExists(mod, "options") && isArray(mod.options)) { saveOptionsRecursive(mod.options, newModID, businessID); } } modSortOrder++; } } itemSortOrder++; } } catSortOrder++; } } response = { "OK": true, "SCHEMA": newSchemaActive ? "unified" : "legacy" }; } catch (any e) { response = { "OK": false, "ERROR": e.message, "DETAIL": e.detail ?: "", "TYPE": e.type ?: "" }; } cfheader(name="Content-Type", value="application/json"); writeOutput(serializeJSON(response));