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/items.cfm
John Mizerek ea34f302ac Require TaxRate and PayfritFee to be configured - no fallbacks
Both values MUST be set per business. If not configured, the menu
API will return an error instead of silently using defaults.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 11:31:31 -08:00

581 lines
20 KiB
Text

<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cffunction name="readJsonBody" access="public" returntype="struct" output="false">
<cfset var raw = getHttpRequestData().content>
<cfif isNull(raw) OR len(trim(raw)) EQ 0>
<cfreturn {}>
</cfif>
<cftry>
<cfset var data = deserializeJSON(raw)>
<cfif isStruct(data)>
<cfreturn data>
<cfelse>
<cfreturn {}>
</cfif>
<cfcatch>
<cfreturn {}>
</cfcatch>
</cftry>
</cffunction>
<cffunction name="apiAbort" access="public" returntype="void" output="true">
<cfargument name="payload" type="struct" required="true">
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#serializeJSON(arguments.payload)#</cfoutput>
<cfabort>
</cffunction>
<cfset data = readJsonBody()>
<cfset BusinessID = 0>
<cfif structKeyExists(data, "BusinessID")>
<cfset BusinessID = val(data.BusinessID)>
</cfif>
<!--- Optional OrderTypeID for channel filtering (1=Dine-In, 2=Takeaway, 3=Delivery) --->
<cfset OrderTypeID = 0>
<cfif structKeyExists(data, "OrderTypeID")>
<cfset OrderTypeID = val(data.OrderTypeID)>
</cfif>
<!--- Optional MenuID for menu filtering (0 = all active menus) --->
<cfset requestedMenuID = 0>
<cfif structKeyExists(data, "MenuID")>
<cfset requestedMenuID = val(data.MenuID)>
</cfif>
<cfif BusinessID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
</cfif>
<!--- Get current time and day for schedule filtering --->
<cfset currentTime = timeFormat(now(), "HH:mm:ss")>
<cfset currentDayID = dayOfWeek(now())>
<cfset menuList = []>
<cftry>
<!--- Check if new schema is active (BusinessID column exists and has data) --->
<cfset newSchemaActive = false>
<cftry>
<cfset qCheck = queryTimed(
"SELECT COUNT(*) as cnt FROM Items WHERE BusinessID = ? AND BusinessID > 0",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset newSchemaActive = (qCheck.cnt GT 0)>
<cfcatch>
<!--- Column doesn't exist yet, use old schema --->
<cfset newSchemaActive = false>
</cfcatch>
</cftry>
<cfif newSchemaActive>
<!--- NEW SCHEMA: Items have BusinessID, try Categories table first, fallback to parent Items --->
<!--- Check if Categories table has data for this business --->
<cfset hasCategoriesData = false>
<cftry>
<cfset qCatCheck = queryTimed(
"SELECT COUNT(*) as cnt FROM Categories WHERE BusinessID = ?",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset hasCategoriesData = (qCatCheck.cnt GT 0)>
<cfcatch>
<cfset hasCategoriesData = false>
</cfcatch>
</cftry>
<cfif hasCategoriesData>
<!--- Use Categories table with CategoryID --->
<!--- Get all active menus for this business (for chip selector) --->
<cfset activeMenuIds = "">
<cfset menuList = []>
<cftry>
<cfset qAllMenus = queryTimed(
"
SELECT ID, Name FROM Menus
WHERE BusinessID = :bizId
AND IsActive = 1
ORDER BY SortOrder, Name
",
{
bizId: { value = BusinessID, cfsqltype = "cf_sql_integer" }
},
{ datasource = "payfrit" }
)>
<cfif requestedMenuID GT 0>
<!--- User selected a specific menu --->
<cfset activeMenuIds = requestedMenuID>
<cfelse>
<!--- No specific menu selected — show all items from all menus --->
<cfset activeMenuIds = valueList(qAllMenus.ID)>
</cfif>
<!--- Build menu list for the response (all active menus, always) --->
<cfloop query="qAllMenus">
<cfset arrayAppend(menuList, {
"MenuID": qAllMenus.ID,
"Name": qAllMenus.Name
})>
</cfloop>
<cfcatch>
<!--- Menus table might not exist yet --->
</cfcatch>
</cftry>
<!--- Get category headers as virtual items --->
<!--- Apply schedule filtering, order type filtering, and menu filtering --->
<cfset qCategories = queryTimed(
"
SELECT
ID,
Name,
SortOrder,
OrderTypes,
ScheduleStart,
ScheduleEnd,
ScheduleDays,
MenuID
FROM Categories
WHERE BusinessID = :bizId
AND (
:orderTypeId = 0
OR FIND_IN_SET(:orderTypeId, OrderTypes) > 0
)
AND (
ScheduleStart IS NULL
OR ScheduleEnd IS NULL
OR (
TIME(:currentTime) >= ScheduleStart
AND TIME(:currentTime) <= ScheduleEnd
)
)
AND (
ScheduleDays IS NULL
OR ScheduleDays = ''
OR FIND_IN_SET(:currentDay, ScheduleDays) > 0
)
AND (
MenuID IS NULL
OR MenuID = 0
#len(activeMenuIds) ? "OR MenuID IN (#activeMenuIds#)" : ""#
)
ORDER BY SortOrder
",
{
bizId: { value = BusinessID, cfsqltype = "cf_sql_integer" },
orderTypeId: { value = OrderTypeID, cfsqltype = "cf_sql_integer" },
currentTime: { value = currentTime, cfsqltype = "cf_sql_varchar" },
currentDay: { value = currentDayID, cfsqltype = "cf_sql_integer" }
},
{ datasource = "payfrit" }
)>
<!--- Get menu items --->
<!--- Exclude old-style category headers (ParentID=0 AND CategoryID=0 AND no children with CategoryID>0) --->
<!--- These are legacy category headers that should be replaced by Categories table entries --->
<!--- Only include items from visible categories (after schedule/channel filtering) --->
<cfset visibleCategoryIds = valueList(qCategories.ID)>
<cfif len(trim(visibleCategoryIds)) EQ 0>
<cfset visibleCategoryIds = "0">
</cfif>
<cfset q = queryTimed(
"
SELECT
i.ID,
i.CategoryID,
c.Name AS CategoryName,
i.Name AS ItemName,
i.Description,
i.ParentItemID,
i.Price,
i.IsActive,
i.IsCheckedByDefault,
i.RequiresChildSelection,
i.MaxNumSelectionReq,
i.IsCollapsible,
i.SortOrder,
i.StationID,
s.Name AS StationName,
s.Color,
c.MenuID
FROM Items i
LEFT JOIN Categories c ON c.ID = i.CategoryID
LEFT JOIN Stations s ON s.ID = i.StationID
WHERE i.BusinessID = :bizId
AND i.IsActive = 1
AND (i.CategoryID IN (#visibleCategoryIds#) OR (i.CategoryID = 0 AND i.ParentItemID > 0))
AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = i.ID)
AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl2 WHERE tl2.TemplateItemID = i.ParentItemID)
AND NOT (i.ParentItemID = 0 AND i.CategoryID = 0 AND i.Price = 0)
ORDER BY COALESCE(c.SortOrder, 999), i.SortOrder, i.ID
",
{ bizId: { value = BusinessID, cfsqltype = "cf_sql_integer" } },
{ datasource = "payfrit" }
)>
<cfelse>
<!--- Fallback: Derive categories from parent Items --->
<cfset q = queryTimed(
"
SELECT
i.ID,
CASE
WHEN i.ParentItemID = 0 AND i.IsCollapsible = 0 THEN i.ID
ELSE COALESCE(
(SELECT cat.ID FROM Items cat
WHERE cat.ID = i.ParentItemID
AND cat.ParentItemID = 0
AND cat.IsCollapsible = 0),
0
)
END as CategoryID,
CASE
WHEN i.ParentItemID = 0 AND i.IsCollapsible = 0 THEN i.Name
ELSE COALESCE(
(SELECT cat.Name FROM Items cat
WHERE cat.ID = i.ParentItemID
AND cat.ParentItemID = 0
AND cat.IsCollapsible = 0),
''
)
END as Name,
i.Name,
i.Description,
i.ParentItemID,
i.Price,
i.IsActive,
i.IsCheckedByDefault,
i.RequiresChildSelection,
i.MaxNumSelectionReq,
i.IsCollapsible,
i.SortOrder,
i.StationID,
s.Name,
s.Color
FROM Items i
LEFT JOIN Stations s ON s.ID = i.StationID
WHERE i.BusinessID = ?
AND i.IsActive = 1
AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = i.ID)
AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl2 WHERE tl2.TemplateItemID = i.ParentItemID)
AND (
i.ParentItemID > 0
OR (i.ParentItemID = 0 AND i.IsCollapsible = 0)
)
ORDER BY i.ParentItemID, i.SortOrder, i.ID
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
</cfif>
<cfelse>
<!--- OLD SCHEMA: Use Categories table --->
<cfset q = queryTimed(
"
SELECT
i.ID,
i.CategoryID,
c.Name,
i.Name,
i.Description,
i.ParentItemID,
i.Price,
i.IsActive,
i.IsCheckedByDefault,
i.RequiresChildSelection,
i.MaxNumSelectionReq,
i.IsCollapsible,
i.SortOrder,
i.StationID,
s.Name,
s.Color
FROM Items i
INNER JOIN Categories c ON c.ID = i.CategoryID
LEFT JOIN Stations s ON s.ID = i.StationID
WHERE c.BusinessID = ?
ORDER BY i.ParentItemID, i.SortOrder, i.ID
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
</cfif>
<cfset rows = []>
<!--- Build set of category IDs that actually have items --->
<cfset categoriesWithItems = {}>
<cfloop query="q">
<cfif q.CategoryID GT 0>
<cfset categoriesWithItems[q.CategoryID] = true>
</cfif>
</cfloop>
<!--- For unified schema with Categories table, add category headers (only if they have items) --->
<cfif newSchemaActive AND isDefined("qCategories")>
<cfloop query="qCategories">
<cfif structKeyExists(categoriesWithItems, qCategories.ID)>
<!--- Add category as a virtual parent item --->
<cfset arrayAppend(rows, {
"ItemID": qCategories.ID,
"CategoryID": qCategories.ID,
"Name": qCategories.Name,
"Description": "",
"ParentItemID": 0,
"Price": 0,
"IsActive": 1,
"IsCheckedByDefault": 0,
"RequiresChildSelection": 0,
"MaxNumSelectionReq": 0,
"IsCollapsible": 0,
"SortOrder": qCategories.SortOrder,
"MenuID": isNull(qCategories.MenuID) ? 0 : val(qCategories.MenuID),
"StationID": "",
"ItemName": "",
"ItemColor": ""
})>
</cfif>
</cfloop>
</cfif>
<!--- Build a set of category IDs for quick lookup --->
<cfset categoryIdSet = {}>
<cfif isDefined("qCategories")>
<cfloop query="qCategories">
<cfset categoryIdSet[qCategories.ID] = true>
</cfloop>
</cfif>
<cfloop query="q">
<!--- For unified schema with Categories: set ParentItemID to CategoryID for top-level items --->
<!--- Remap old parent IDs to category IDs --->
<cfset effectiveParentID = q.ParentItemID>
<cfif newSchemaActive AND isDefined("qCategories") AND q.CategoryID GT 0>
<cfif q.ParentItemID EQ 0>
<!--- Item has no parent but has a category - link to category --->
<cfset effectiveParentID = q.CategoryID>
<cfelseif structKeyExists(categoryIdSet, q.ParentItemID)>
<!--- Item's parent IS a category ID - this is correct, keep it --->
<cfset effectiveParentID = q.ParentItemID>
<cfelseif NOT structKeyExists(categoryIdSet, q.ParentItemID)>
<!--- Parent ID is an old-style category header - remap to CategoryID --->
<cfset effectiveParentID = q.CategoryID>
</cfif>
</cfif>
<cfset itemMenuID = 0>
<cftry>
<cfset itemMenuID = isNull(q.MenuID) ? 0 : val(q.MenuID)>
<cfcatch><cfset itemMenuID = 0></cfcatch>
</cftry>
<!--- Use aliased columns if available, fall back to generic Name --->
<cfset itemName = "">
<cfset catName = "">
<cftry>
<cfset itemName = q.ItemName>
<cfset catName = q.CategoryName>
<cfcatch>
<!--- Fallback for old schema queries without aliases --->
<cfset itemName = q.Name>
<cfset catName = q.Name>
</cfcatch>
</cftry>
<cfset arrayAppend(rows, {
"ItemID": q.ID,
"CategoryID": q.CategoryID,
"Name": len(trim(itemName)) ? itemName : catName,
"Description": q.Description,
"ParentItemID": effectiveParentID,
"Price": q.Price,
"IsActive": q.IsActive,
"IsCheckedByDefault": q.IsCheckedByDefault,
"RequiresChildSelection": q.RequiresChildSelection,
"MaxNumSelectionReq": q.MaxNumSelectionReq,
"IsCollapsible": q.IsCollapsible,
"SortOrder": q.SortOrder,
"MenuID": itemMenuID,
"StationID": len(trim(q.StationID)) ? q.StationID : "",
"ItemName": len(trim(catName)) ? catName : "",
"ItemColor": len(trim(q.Color)) ? q.Color : ""
})>
</cfloop>
<!--- For unified schema: Add template-linked modifiers as virtual children of menu items --->
<cfif newSchemaActive>
<!--- Get template links: which menu items use which templates --->
<cfset qTemplateLinks = queryTimed(
"
SELECT
tl.ItemID as MenuItemID,
tmpl.ID as TemplateItemID,
tmpl.Name as TemplateName,
tmpl.Description as TemplateDescription,
tmpl.RequiresChildSelection as TemplateRequired,
tmpl.MaxNumSelectionReq as TemplateMaxSelections,
tmpl.IsCollapsible as TemplateIsCollapsible,
tl.SortOrder as TemplateSortOrder
FROM lt_ItemID_TemplateItemID tl
INNER JOIN Items tmpl ON tmpl.ID = tl.TemplateItemID AND tmpl.IsActive = 1
INNER JOIN Items menuItem ON menuItem.ID = tl.ItemID
WHERE menuItem.BusinessID = ?
AND menuItem.IsActive = 1
ORDER BY tl.ItemID, tl.SortOrder
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Get template options --->
<cfset qTemplateOptions = queryTimed(
"
SELECT DISTINCT
opt.ID as OptionItemID,
opt.ParentItemID as TemplateItemID,
opt.Name as OptionName,
opt.Description as OptionDescription,
opt.Price as OptionPrice,
opt.IsCheckedByDefault as OptionIsDefault,
opt.SortOrder as OptionSortOrder
FROM Items opt
INNER JOIN lt_ItemID_TemplateItemID tl ON tl.TemplateItemID = opt.ParentItemID
INNER JOIN Items menuItem ON menuItem.ID = tl.ItemID
WHERE menuItem.BusinessID = ?
AND menuItem.IsActive = 1
AND opt.IsActive = 1
ORDER BY opt.ParentItemID, opt.SortOrder
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Build template options map: templateID -> [options] --->
<cfset templateOptionsMap = {}>
<cfloop query="qTemplateOptions">
<cfif NOT structKeyExists(templateOptionsMap, qTemplateOptions.TemplateItemID)>
<cfset templateOptionsMap[qTemplateOptions.TemplateItemID] = []>
</cfif>
<cfset arrayAppend(templateOptionsMap[qTemplateOptions.TemplateItemID], {
"ItemID": qTemplateOptions.OptionItemID,
"Name": qTemplateOptions.OptionName,
"Description": qTemplateOptions.OptionDescription,
"Price": qTemplateOptions.OptionPrice,
"IsCheckedByDefault": qTemplateOptions.OptionIsDefault,
"SortOrder": qTemplateOptions.OptionSortOrder
})>
</cfloop>
<!--- Add templates and their options as virtual children --->
<!--- Use virtual IDs to make each template instance unique per menu item --->
<!--- Virtual ID format: menuItemID * 100000 + templateID for templates --->
<!--- menuItemID * 100000 + optionID for options --->
<cfset addedTemplates = {}>
<cfloop query="qTemplateLinks">
<cfset menuItemID = qTemplateLinks.MenuItemID>
<cfset templateID = qTemplateLinks.TemplateItemID>
<cfset linkKey = menuItemID & "_" & templateID>
<!--- Skip duplicates --->
<cfif structKeyExists(addedTemplates, linkKey)>
<cfcontinue>
</cfif>
<cfset addedTemplates[linkKey] = true>
<!--- Generate unique virtual ID for this template instance --->
<cfset virtualTemplateID = menuItemID * 100000 + templateID>
<!--- Add template as modifier group (child of menu item) --->
<cfset arrayAppend(rows, {
"ItemID": virtualTemplateID,
"CategoryID": 0,
"Name": "",
"Name": qTemplateLinks.TemplateName,
"Description": qTemplateLinks.TemplateDescription,
"ParentItemID": menuItemID,
"Price": 0,
"IsActive": 1,
"IsCheckedByDefault": 0,
"RequiresChildSelection": qTemplateLinks.TemplateRequired,
"MaxNumSelectionReq": qTemplateLinks.TemplateMaxSelections,
"IsCollapsible": qTemplateLinks.TemplateIsCollapsible,
"SortOrder": qTemplateLinks.TemplateSortOrder,
"StationID": "",
"ItemName": "",
"ItemColor": ""
})>
<!--- Add template options as children of virtual template --->
<cfif structKeyExists(templateOptionsMap, templateID)>
<cfloop array="#templateOptionsMap[templateID]#" index="opt">
<!--- Generate unique virtual ID for this option instance --->
<cfset virtualOptionID = menuItemID * 100000 + opt.ItemID>
<cfset arrayAppend(rows, {
"ItemID": virtualOptionID,
"CategoryID": 0,
"Name": "",
"Name": opt.Name,
"Description": opt.Description,
"ParentItemID": virtualTemplateID,
"Price": opt.Price,
"IsActive": 1,
"IsCheckedByDefault": opt.IsCheckedByDefault,
"RequiresChildSelection": 0,
"MaxNumSelectionReq": 0,
"IsCollapsible": 0,
"SortOrder": opt.SortOrder,
"StationID": "",
"ItemName": "",
"ItemColor": ""
})>
</cfloop>
</cfif>
</cfloop>
</cfif>
<!--- Get brand color, tax rate, payfrit fee, and header image for this business --->
<cfset brandColor = "">
<cfset headerImageUrl = "">
<cfset qBrand = queryTimed(
"SELECT BrandColor AS BusinessBrandColor, TaxRate, PayfritFee, HeaderImageExtension FROM Businesses WHERE ID = ?",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qBrand.recordCount EQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "business_not_found" })>
</cfif>
<!--- TaxRate and PayfritFee MUST be configured - no fallbacks --->
<cfif NOT isNumeric(qBrand.TaxRate)>
<cfset apiAbort({ "OK": false, "ERROR": "business_tax_rate_not_configured" })>
</cfif>
<cfif NOT isNumeric(qBrand.PayfritFee) OR qBrand.PayfritFee LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "business_payfrit_fee_not_configured" })>
</cfif>
<cfset businessTaxRate = qBrand.TaxRate>
<cfset businessPayfritFee = qBrand.PayfritFee>
<cfif len(trim(qBrand.BusinessBrandColor))>
<cfset brandColor = left(qBrand.BusinessBrandColor, 1) EQ chr(35) ? qBrand.BusinessBrandColor : chr(35) & qBrand.BusinessBrandColor>
</cfif>
<cfif len(trim(qBrand.HeaderImageExtension))>
<cfset headerImageUrl = "/uploads/headers/#BusinessID#.#qBrand.HeaderImageExtension#">
</cfif>
<cfset apiAbort({
"OK": true,
"ERROR": "",
"Items": rows,
"COUNT": arrayLen(rows),
"SCHEMA": newSchemaActive ? "unified" : "legacy",
"BRANDCOLOR": brandColor,
"HEADERIMAGEURL": headerImageUrl,
"TAXRATE": val(businessTaxRate),
"PAYFRITFEE": val(businessPayfritFee),
"Menus": menuList,
"SelectedMenuID": requestedMenuID
})>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "DB error loading items",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>