/** * Get Menu for Builder * Returns categories and items in structured format for the menu builder UI */ response = { "OK": false }; // Pre-index query rows by parentId for O(1) lookup instead of O(n) scan per level function buildParentIndex(allOptions) { var byParent = {}; for (var i = 1; i <= allOptions.recordCount; i++) { var pid = allOptions.ParentItemID[i]; if (!structKeyExists(byParent, pid)) byParent[pid] = []; arrayAppend(byParent[pid], i); } return byParent; } // Recursive function to build nested options (uses pre-built index) function buildOptionsTree(allOptions, parentId, byParent) { if (!structKeyExists(byParent, parentId)) return []; var result = []; for (var idx in byParent[parentId]) { var children = buildOptionsTree(allOptions, allOptions.ItemID[idx], byParent); arrayAppend(result, { "id": "opt_" & allOptions.ItemID[idx], "dbId": allOptions.ItemID[idx], "name": allOptions.ItemName[idx], "price": allOptions.ItemPrice[idx], "isDefault": allOptions.IsDefault[idx] == 1 ? true : false, "sortOrder": allOptions.ItemSortOrder[idx], "requiresSelection": isNull(allOptions.RequiresSelection[idx]) ? false : (allOptions.RequiresSelection[idx] == 1), "maxSelections": isNull(allOptions.MaxSelections[idx]) ? 0 : allOptions.MaxSelections[idx], "options": children }); } if (arrayLen(result) > 1) { arraySort(result, function(a, b) { return a.sortOrder - b.sortOrder; }); } return result; } try { requestBody = toString(getHttpRequestData().content); requestData = {}; if (len(requestBody)) { requestData = deserializeJSON(requestBody); } businessID = 0; if (structKeyExists(requestData, "BusinessID")) { businessID = val(requestData.BusinessID); } if (businessID == 0) { response["ERROR"] = "missing_business_id"; response["MESSAGE"] = "BusinessID is required"; writeOutput(serializeJSON(response)); abort; } // Check for MenuID filter (optional - if provided, only return categories for that menu) menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0; // Get all menus for this business allMenus = []; try { qMenus = queryTimed(" SELECT MenuID, MenuName, MenuDescription, MenuDaysActive, MenuStartTime, MenuEndTime, MenuSortOrder FROM Menus WHERE MenuBusinessID = :businessID AND MenuIsActive = 1 ORDER BY MenuSortOrder, MenuName ", { businessID: businessID }, { datasource: "payfrit" }); for (m = 1; m <= qMenus.recordCount; m++) { arrayAppend(allMenus, { "MenuID": qMenus.MenuID[m], "MenuName": qMenus.MenuName[m], "MenuDescription": isNull(qMenus.MenuDescription[m]) ? "" : qMenus.MenuDescription[m], "MenuDaysActive": qMenus.MenuDaysActive[m], "MenuStartTime": isNull(qMenus.MenuStartTime[m]) ? "" : timeFormat(qMenus.MenuStartTime[m], "HH:mm"), "MenuEndTime": isNull(qMenus.MenuEndTime[m]) ? "" : timeFormat(qMenus.MenuEndTime[m], "HH:mm"), "MenuSortOrder": qMenus.MenuSortOrder[m] }); } // Auto-select menu based on current time when no specific menu requested if (menuID == 0 && qMenus.recordCount > 1) { now = timeFormat(now(), "HH:mm"); dayOfWeek = dayOfWeek(now()); // 1=Sun, 2=Mon, ... 7=Sat dayBit = 2 ^ (dayOfWeek - 1); // bitmask: 1=Sun, 2=Mon, 4=Tue, etc. activeMenuIds = []; for (m = 1; m <= qMenus.recordCount; m++) { // Check if menu is active today (days bitmask) menuDays = qMenus.MenuDaysActive[m]; if (bitAnd(menuDays, dayBit) == 0) continue; // Check time range hasStart = !isNull(qMenus.MenuStartTime[m]); hasEnd = !isNull(qMenus.MenuEndTime[m]); if (hasStart && hasEnd) { startT = timeFormat(qMenus.MenuStartTime[m], "HH:mm"); endT = timeFormat(qMenus.MenuEndTime[m], "HH:mm"); if (now >= startT && now <= endT) { arrayAppend(activeMenuIds, qMenus.MenuID[m]); } } else { // No time restriction = always active arrayAppend(activeMenuIds, qMenus.MenuID[m]); } } // If exactly one menu is active now, auto-select it if (arrayLen(activeMenuIds) == 1) { menuID = activeMenuIds[1]; } // If multiple match (overlap) or none match, show all (menuID stays 0) } } catch (any e) { // Menus table might not exist yet } // Check if Categories table has data for this business hasCategoriesData = false; try { qCatCheck = queryTimed(" SELECT 1 FROM Categories WHERE CategoryBusinessID = :businessID LIMIT 1 ", { businessID: businessID }, { datasource: "payfrit" }); hasCategoriesData = (qCatCheck.recordCount > 0); } catch (any e) { hasCategoriesData = false; } if (hasCategoriesData) { // OLD SCHEMA: Use Categories table for categories // Build menu filter clause menuFilter = ""; menuParams = { businessID: businessID }; if (menuID > 0) { menuFilter = " AND CategoryMenuID = :menuID"; menuParams["menuID"] = menuID; } qCategories = queryTimed(" SELECT CategoryID, CategoryName, CategorySortOrder as ItemSortOrder, CategoryMenuID FROM Categories WHERE CategoryBusinessID = :businessID #menuFilter# ORDER BY CategorySortOrder, CategoryName ", menuParams, { datasource: "payfrit" }); // Get menu items - items that belong to categories (not modifiers) qItems = queryTimed(" SELECT i.ItemID, i.ItemCategoryID as CategoryItemID, i.ItemName, i.ItemDescription, i.ItemPrice, i.ItemSortOrder, i.ItemIsActive FROM Items i WHERE i.ItemBusinessID = :businessID AND i.ItemIsActive = 1 AND i.ItemCategoryID > 0 ORDER BY i.ItemSortOrder, i.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); // Get direct modifiers (items with ParentItemID pointing to menu items, not categories) qDirectModifiers = queryTimed(" SELECT m.ItemID, m.ItemParentItemID as ParentItemID, m.ItemName, m.ItemPrice, m.ItemIsCheckedByDefault as IsDefault, m.ItemSortOrder, m.ItemRequiresChildSelection as RequiresSelection, m.ItemMaxNumSelectionReq as MaxSelections FROM Items m WHERE m.ItemBusinessID = :businessID AND m.ItemIsActive = 1 AND m.ItemParentItemID > 0 AND (m.ItemCategoryID = 0 OR m.ItemCategoryID IS NULL) ORDER BY m.ItemSortOrder, m.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); } else { // NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children qCategories = queryTimed(" SELECT DISTINCT p.ItemID as CategoryID, p.ItemName as CategoryName, p.ItemSortOrder FROM Items p INNER JOIN Items c ON c.ItemParentItemID = p.ItemID WHERE p.ItemBusinessID = :businessID AND p.ItemParentItemID = 0 AND p.ItemIsActive = 1 AND NOT EXISTS ( SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = p.ItemID ) ORDER BY p.ItemSortOrder, p.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); qItems = queryTimed(" SELECT i.ItemID, i.ItemParentItemID as CategoryItemID, i.ItemName, i.ItemDescription, i.ItemPrice, i.ItemSortOrder, i.ItemIsActive FROM Items i INNER JOIN Items cat ON cat.ItemID = i.ItemParentItemID WHERE i.ItemBusinessID = :businessID AND i.ItemIsActive = 1 AND cat.ItemParentItemID = 0 AND NOT EXISTS ( SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = cat.ItemID ) ORDER BY i.ItemSortOrder, i.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); qDirectModifiers = queryTimed(" SELECT m.ItemID, m.ItemParentItemID as ParentItemID, m.ItemName, m.ItemPrice, m.ItemIsCheckedByDefault as IsDefault, m.ItemSortOrder, m.ItemRequiresChildSelection as RequiresSelection, m.ItemMaxNumSelectionReq as MaxSelections FROM Items m WHERE m.ItemBusinessID = :businessID AND m.ItemIsActive = 1 AND m.ItemParentItemID > 0 ORDER BY m.ItemSortOrder, m.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); } // Collect menu item IDs for filtering template links menuItemIds = []; for (i = 1; i <= qItems.recordCount; i++) { arrayAppend(menuItemIds, qItems.ItemID[i]); } // Get template links ONLY for this business's menu items qTemplateLinks = queryNew("ParentItemID,TemplateItemID,SortOrder"); if (arrayLen(menuItemIds) > 0) { qTemplateLinks = queryTimed(" SELECT tl.ItemID as ParentItemID, tl.TemplateItemID, tl.SortOrder FROM ItemTemplateLinks tl WHERE tl.ItemID IN (:itemIds) ORDER BY tl.ItemID, tl.SortOrder ", { itemIds: { value: arrayToList(menuItemIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" }); } // Get templates for this business only qTemplates = queryTimed(" SELECT DISTINCT t.ItemID, t.ItemName, t.ItemPrice, t.ItemIsCheckedByDefault as IsDefault, t.ItemSortOrder, t.ItemRequiresChildSelection as RequiresSelection, t.ItemMaxNumSelectionReq as MaxSelections FROM Items t WHERE t.ItemBusinessID = :businessID AND (t.ItemCategoryID = 0 OR t.ItemCategoryID IS NULL) AND t.ItemParentItemID = 0 AND t.ItemIsActive = 1 ORDER BY t.ItemSortOrder, t.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); // Get template children (options within templates) templateIds = []; for (i = 1; i <= qTemplates.recordCount; i++) { arrayAppend(templateIds, qTemplates.ItemID[i]); } qTemplateChildren = queryNew("ItemID,ParentItemID,ItemName,ItemPrice,IsDefault,ItemSortOrder,RequiresSelection,MaxSelections"); if (arrayLen(templateIds) > 0) { qTemplateChildren = queryTimed(" SELECT c.ItemID, c.ItemParentItemID as ParentItemID, c.ItemName, c.ItemPrice, c.ItemIsCheckedByDefault as IsDefault, c.ItemSortOrder, c.ItemRequiresChildSelection as RequiresSelection, c.ItemMaxNumSelectionReq as MaxSelections FROM Items c WHERE c.ItemParentItemID IN (:templateIds) AND c.ItemIsActive = 1 ORDER BY c.ItemSortOrder, c.ItemName ", { templateIds: { value: arrayToList(templateIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" }); } // Build templates lookup with their options (pre-index for fast tree building) templateChildrenIndex = buildParentIndex(qTemplateChildren); templatesById = {}; for (i = 1; i <= qTemplates.recordCount; i++) { templateID = qTemplates.ItemID[i]; options = buildOptionsTree(qTemplateChildren, templateID, templateChildrenIndex); templatesById[templateID] = { "id": "mod_" & qTemplates.ItemID[i], "dbId": qTemplates.ItemID[i], "name": qTemplates.ItemName[i], "price": qTemplates.ItemPrice[i], "isDefault": qTemplates.IsDefault[i] == 1 ? true : false, "sortOrder": qTemplates.ItemSortOrder[i], "isTemplate": true, "requiresSelection": isNull(qTemplates.RequiresSelection[i]) ? false : (qTemplates.RequiresSelection[i] == 1), "maxSelections": isNull(qTemplates.MaxSelections[i]) ? 0 : qTemplates.MaxSelections[i], "options": options }; } // Build template links lookup by parent ItemID templateLinksByItem = {}; for (i = 1; i <= qTemplateLinks.recordCount; i++) { parentID = qTemplateLinks.ParentItemID[i]; templateID = qTemplateLinks.TemplateItemID[i]; if (!structKeyExists(templateLinksByItem, parentID)) { templateLinksByItem[parentID] = []; } if (structKeyExists(templatesById, templateID)) { tmpl = duplicate(templatesById[templateID]); tmpl["sortOrder"] = qTemplateLinks.SortOrder[i]; arrayAppend(templateLinksByItem[parentID], tmpl); } } // Build nested direct modifiers for each menu item (pre-index for fast tree building) directModifiersIndex = buildParentIndex(qDirectModifiers); directModsByItem = {}; for (itemId in menuItemIds) { options = buildOptionsTree(qDirectModifiers, itemId, directModifiersIndex); if (arrayLen(options) > 0) { directModsByItem[itemId] = options; } } // Build items lookup by CategoryID itemsByCategory = {}; for (i = 1; i <= qItems.recordCount; i++) { catID = qItems.CategoryItemID[i]; if (!structKeyExists(itemsByCategory, catID)) { itemsByCategory[catID] = []; } itemID = qItems.ItemID[i]; // Get template-linked modifiers itemModifiers = structKeyExists(templateLinksByItem, itemID) ? duplicate(templateLinksByItem[itemID]) : []; // Add direct modifiers if (structKeyExists(directModsByItem, itemID)) { directMods = directModsByItem[itemID]; for (j = 1; j <= arrayLen(directMods); j++) { arrayAppend(itemModifiers, directMods[j]); } } // Sort modifiers by sortOrder if (arrayLen(itemModifiers) > 1) { arraySort(itemModifiers, function(a, b) { return a.sortOrder - b.sortOrder; }); } // Check for existing item photo itemImageUrl = ""; itemsDir = expandPath("/uploads/items"); for (ext in ["jpg","jpeg","png","gif","webp","JPG","JPEG","PNG","GIF","WEBP"]) { if (fileExists(itemsDir & "/" & qItems.ItemID[i] & "." & ext)) { itemImageUrl = "/uploads/items/" & qItems.ItemID[i] & "." & ext; break; } } arrayAppend(itemsByCategory[catID], { "id": "item_" & qItems.ItemID[i], "dbId": qItems.ItemID[i], "name": qItems.ItemName[i], "description": isNull(qItems.ItemDescription[i]) ? "" : qItems.ItemDescription[i], "price": qItems.ItemPrice[i], "imageUrl": len(itemImageUrl) ? itemImageUrl : javaCast("null", ""), "photoTaskId": javaCast("null", ""), "modifiers": itemModifiers, "sortOrder": qItems.ItemSortOrder[i] }); } // Build categories array categories = []; catIndex = 0; for (i = 1; i <= qCategories.recordCount; i++) { catID = qCategories.CategoryID[i]; catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : []; catStruct = { "id": "cat_" & qCategories.CategoryID[i], "dbId": qCategories.CategoryID[i], "name": qCategories.CategoryName[i], "description": "", "sortOrder": catIndex, "items": catItems }; // Include MenuID if available (legacy schema with Categories table) if (hasCategoriesData) { try { catStruct["menuId"] = isNull(qCategories.CategoryMenuID[i]) ? 0 : val(qCategories.CategoryMenuID[i]); } catch (any e) { catStruct["menuId"] = 0; } } arrayAppend(categories, catStruct); catIndex++; } // Build template library array templateLibrary = []; for (templateID in templatesById) { arrayAppend(templateLibrary, templatesById[templateID]); } // Get business brand color brandColor = ""; try { qBrand = queryTimed(" SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = :bizId ", { bizId: businessID }, { datasource: "payfrit" }); if (qBrand.recordCount > 0 && len(trim(qBrand.BusinessBrandColor))) { brandColor = left(qBrand.BusinessBrandColor, 1) == chr(35) ? qBrand.BusinessBrandColor : chr(35) & qBrand.BusinessBrandColor; } } catch (any e) { // Column may not exist yet, ignore } response["OK"] = true; response["MENU"] = { "categories": categories }; response["MENUS"] = allMenus; response["SELECTED_MENU_ID"] = menuID; response["TEMPLATES"] = templateLibrary; response["BRANDCOLOR"] = brandColor; response["CATEGORY_COUNT"] = arrayLen(categories); response["TEMPLATE_COUNT"] = arrayLen(templateLibrary); response["MENU_COUNT"] = arrayLen(allMenus); response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified"; totalItems = 0; for (cat in categories) { totalItems += arrayLen(cat.items); } response["ITEM_COUNT"] = totalItems; } catch (any e) { response["ERROR"] = "server_error"; response["MESSAGE"] = e.message; response["DETAIL"] = e.detail ?: ""; } logPerf(); writeOutput(serializeJSON(response));