This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/api/menu/getForBuilder.cfm
John Mizerek 6a73752136 Fix empty categories disappearing in unified schema menu builder
The unified schema query used INNER JOIN to children, which excluded
categories with no items. Changed to direct query with NOT EXISTS
template filter so empty categories persist after save+reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 20:47:19 -07:00

508 lines
19 KiB
Text

<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Get Menu for Builder
* Returns categories and items in structured format for the menu builder UI
*/
response = { "OK": false };
// Recursive function to build nested options
function buildOptionsTree(allOptions, parentId) {
var result = [];
for (var i = 1; i <= allOptions.recordCount; i++) {
if (allOptions.ParentItemID[i] == parentId) {
var children = buildOptionsTree(allOptions, allOptions.ItemID[i]);
arrayAppend(result, {
"id": "opt_" & allOptions.ItemID[i],
"dbId": allOptions.ItemID[i],
"name": allOptions.Name[i],
"price": allOptions.Price[i],
"isDefault": allOptions.IsDefault[i] == 1 ? true : false,
"sortOrder": allOptions.SortOrder[i],
"requiresSelection": isNull(allOptions.RequiresSelection[i]) ? false : (allOptions.RequiresSelection[i] == 1),
"maxSelections": isNull(allOptions.MaxSelections[i]) ? 0 : allOptions.MaxSelections[i],
"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 business's default menu setting and timezone
defaultMenuID = 0;
businessTimezone = "America/Los_Angeles";
try {
qBiz = queryTimed("SELECT DefaultMenuID, Timezone FROM Businesses WHERE ID = :businessID",
{ businessID: businessID }, { datasource: "payfrit" });
if (qBiz.recordCount > 0) {
if (!isNull(qBiz.DefaultMenuID)) defaultMenuID = val(qBiz.DefaultMenuID);
if (!isNull(qBiz.Timezone) && len(trim(qBiz.Timezone))) businessTimezone = qBiz.Timezone;
}
} catch (any e) {}
// Get all menus for this business
allMenus = [];
try {
qMenus = queryTimed("
SELECT ID, Name, Description, DaysActive,
StartTime, EndTime, SortOrder
FROM Menus
WHERE BusinessID = :businessID AND IsActive = 1
ORDER BY SortOrder, Name
", { businessID: businessID }, { datasource: "payfrit" });
for (m = 1; m <= qMenus.recordCount; m++) {
arrayAppend(allMenus, {
"MenuID": qMenus.ID[m],
"MenuName": qMenus.Name[m],
"MenuDescription": isNull(qMenus.Description[m]) ? "" : qMenus.Description[m],
"MenuDaysActive": qMenus.DaysActive[m],
"MenuStartTime": isNull(qMenus.StartTime[m]) ? "" : timeFormat(qMenus.StartTime[m], "HH:mm"),
"MenuEndTime": isNull(qMenus.EndTime[m]) ? "" : timeFormat(qMenus.EndTime[m], "HH:mm"),
"SortOrder": qMenus.SortOrder[m]
});
}
// Auto-select menu based on current time when no specific menu requested
if (menuID == 0 && qMenus.recordCount > 1) {
currentTime = left(getTimeInZone(businessTimezone), 5); // "HH:mm"
currentDay = getDayInZone(businessTimezone); // 1=Sun, 2=Mon, ... 7=Sat
dayBit = 2 ^ (currentDay - 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.DaysActive[m];
if (bitAnd(menuDays, dayBit) == 0) continue;
// Check time range
hasStart = !isNull(qMenus.StartTime[m]);
hasEnd = !isNull(qMenus.EndTime[m]);
if (hasStart && hasEnd) {
startT = timeFormat(qMenus.StartTime[m], "HH:mm");
endT = timeFormat(qMenus.EndTime[m], "HH:mm");
if (currentTime >= startT && currentTime <= endT) {
arrayAppend(activeMenuIds, qMenus.ID[m]);
}
} else {
// No time restriction = always active
arrayAppend(activeMenuIds, qMenus.ID[m]);
}
}
// If exactly one menu is active now, auto-select it
if (arrayLen(activeMenuIds) == 1) {
menuID = activeMenuIds[1];
} else if (arrayLen(activeMenuIds) > 1 && defaultMenuID > 0 && arrayFind(activeMenuIds, defaultMenuID)) {
// Multiple menus active - use business default if it's among the active ones
menuID = defaultMenuID;
}
// If multiple match with no default, 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 BusinessID = :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 MenuID = :menuID";
menuParams["menuID"] = menuID;
}
qCategories = queryTimed("
SELECT
ID,
Name,
ParentCategoryID,
SortOrder as SortOrder,
MenuID
FROM Categories
WHERE BusinessID = :businessID #menuFilter#
ORDER BY SortOrder, Name
", menuParams, { datasource: "payfrit" });
// Get menu items - items that belong to categories (not modifiers)
qItems = queryTimed("
SELECT
i.ID,
i.CategoryID as CategoryItemID,
i.Name,
i.Description,
i.Price,
i.SortOrder,
i.IsActive
FROM Items i
WHERE i.BusinessID = :businessID
AND i.IsActive = 1
AND i.CategoryID > 0
ORDER BY i.SortOrder, i.Name
", { businessID: businessID }, { datasource: "payfrit" });
// Get direct modifiers (items with ParentItemID pointing to menu items, not categories)
qDirectModifiers = queryTimed("
SELECT
m.ID as ItemID,
m.ParentItemID as ParentItemID,
m.Name,
m.Price,
m.IsCheckedByDefault as IsDefault,
m.SortOrder,
m.RequiresChildSelection as RequiresSelection,
m.MaxNumSelectionReq as MaxSelections
FROM Items m
WHERE m.BusinessID = :businessID
AND m.IsActive = 1
AND m.ParentItemID > 0
AND (m.CategoryID = 0 OR m.CategoryID IS NULL)
ORDER BY m.SortOrder, m.Name
", { businessID: businessID }, { datasource: "payfrit" });
} else {
// NEW UNIFIED SCHEMA: Categories are Items at ParentID=0, CategoryID=0, not templates
qCategories = queryTimed("
SELECT
p.ID,
p.Name,
p.SortOrder
FROM Items p
WHERE p.BusinessID = :businessID
AND p.ParentItemID = 0
AND (p.CategoryID = 0 OR p.CategoryID IS NULL)
AND p.IsActive = 1
AND NOT EXISTS (
SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = p.ID
)
ORDER BY p.SortOrder, p.Name
", { businessID: businessID }, { datasource: "payfrit" });
qItems = queryTimed("
SELECT
i.ID,
i.ParentItemID as CategoryItemID,
i.Name,
i.Description,
i.Price,
i.SortOrder,
i.IsActive
FROM Items i
INNER JOIN Items cat ON cat.ID = i.ParentItemID
WHERE i.BusinessID = :businessID
AND i.IsActive = 1
AND cat.ParentItemID = 0
AND NOT EXISTS (
SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = cat.ID
)
ORDER BY i.SortOrder, i.Name
", { businessID: businessID }, { datasource: "payfrit" });
qDirectModifiers = queryTimed("
SELECT
m.ID as ItemID,
m.ParentItemID as ParentItemID,
m.Name,
m.Price,
m.IsCheckedByDefault as IsDefault,
m.SortOrder,
m.RequiresChildSelection as RequiresSelection,
m.MaxNumSelectionReq as MaxSelections
FROM Items m
WHERE m.BusinessID = :businessID
AND m.IsActive = 1
AND m.ParentItemID > 0
ORDER BY m.SortOrder, m.Name
", { businessID: businessID }, { datasource: "payfrit" });
}
// Collect menu item IDs for filtering template links
menuItemIds = [];
for (i = 1; i <= qItems.recordCount; i++) {
arrayAppend(menuItemIds, qItems.ID[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 lt_ItemID_TemplateItemID 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.ID as ItemID,
t.Name,
t.Price,
t.IsCheckedByDefault as IsDefault,
t.SortOrder,
t.RequiresChildSelection as RequiresSelection,
t.MaxNumSelectionReq as MaxSelections
FROM Items t
WHERE t.BusinessID = :businessID
AND (t.CategoryID = 0 OR t.CategoryID IS NULL)
AND t.ParentItemID = 0
AND t.IsActive = 1
ORDER BY t.SortOrder, t.Name
", { 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,Name,Price,IsDefault,SortOrder,RequiresSelection,MaxSelections");
if (arrayLen(templateIds) > 0) {
qTemplateChildren = queryTimed("
SELECT
c.ID as ItemID,
c.ParentItemID as ParentItemID,
c.Name,
c.Price,
c.IsCheckedByDefault as IsDefault,
c.SortOrder,
c.RequiresChildSelection as RequiresSelection,
c.MaxNumSelectionReq as MaxSelections
FROM Items c
WHERE c.ParentItemID IN (:templateIds)
AND c.IsActive = 1
ORDER BY c.SortOrder, c.Name
", { templateIds: { value: arrayToList(templateIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" });
}
// Build templates lookup with their options
templatesById = {};
for (i = 1; i <= qTemplates.recordCount; i++) {
templateID = qTemplates.ItemID[i];
options = buildOptionsTree(qTemplateChildren, templateID);
templatesById[templateID] = {
"id": "mod_" & qTemplates.ItemID[i],
"dbId": qTemplates.ItemID[i],
"name": qTemplates.Name[i],
"price": qTemplates.Price[i],
"isDefault": qTemplates.IsDefault[i] == 1 ? true : false,
"sortOrder": qTemplates.SortOrder[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
directModsByItem = {};
for (itemId in menuItemIds) {
options = buildOptionsTree(qDirectModifiers, itemId);
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.ID[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.ID[i] & "." & ext)) {
itemImageUrl = "/uploads/items/" & qItems.ID[i] & "." & ext;
break;
}
}
arrayAppend(itemsByCategory[catID], {
"id": "item_" & qItems.ID[i],
"dbId": qItems.ID[i],
"name": qItems.Name[i],
"description": isNull(qItems.Description[i]) ? "" : qItems.Description[i],
"price": qItems.Price[i],
"imageUrl": len(itemImageUrl) ? itemImageUrl : javaCast("null", ""),
"photoTaskId": javaCast("null", ""),
"modifiers": itemModifiers,
"sortOrder": qItems.SortOrder[i]
});
}
// Build categories array
categories = [];
catIndex = 0;
for (i = 1; i <= qCategories.recordCount; i++) {
catID = qCategories.ID[i];
catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : [];
catStruct = {
"id": "cat_" & qCategories.ID[i],
"dbId": qCategories.ID[i],
"name": qCategories.Name[i],
"description": "",
"sortOrder": catIndex,
"items": catItems
};
// Include MenuID and ParentCategoryID if available (legacy schema with Categories table)
if (hasCategoriesData) {
try {
catStruct["menuId"] = isNull(qCategories.MenuID[i]) ? 0 : val(qCategories.MenuID[i]);
} catch (any e) {
catStruct["menuId"] = 0;
}
try {
catStruct["parentCategoryId"] = isNull(qCategories.ParentCategoryID[i]) ? 0 : val(qCategories.ParentCategoryID[i]);
catStruct["parentCategoryDbId"] = catStruct["parentCategoryId"];
} catch (any e) {
catStruct["parentCategoryId"] = 0;
catStruct["parentCategoryDbId"] = 0;
}
}
arrayAppend(categories, catStruct);
catIndex++;
}
// Build template library array
templateLibrary = [];
for (templateID in templatesById) {
arrayAppend(templateLibrary, templatesById[templateID]);
}
// Get business brand colors
brandColor = "";
brandColorLight = "";
try {
qBrand = queryTimed("
SELECT BrandColor, BrandColorLight FROM Businesses WHERE ID = :bizId
", { bizId: businessID }, { datasource: "payfrit" });
if (qBrand.recordCount > 0) {
if (len(trim(qBrand.BrandColor)))
brandColor = left(qBrand.BrandColor, 1) == chr(35) ? qBrand.BrandColor : chr(35) & qBrand.BrandColor;
if (len(trim(qBrand.BrandColorLight)))
brandColorLight = left(qBrand.BrandColorLight, 1) == chr(35) ? qBrand.BrandColorLight : chr(35) & qBrand.BrandColorLight;
}
} 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["DEFAULT_MENU_ID"] = defaultMenuID;
response["TEMPLATES"] = templateLibrary;
response["BRANDCOLOR"] = brandColor;
response["BRANDCOLORLIGHT"] = brandColorLight;
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 ?: "";
}
try{logPerf(0);}catch(any e){}
writeOutput(serializeJSON(response));
</cfscript>