payfrit-works/api/menu/items.cfm
John Mizerek e757a4140b Add drink modifiers, unified schema improvements, and portal fixes
Menu System:
- Unified schema with Categories table integration
- Virtual category headers with proper parent ID remapping
- Filter out legacy category headers when using new schema
- Add drink modifier endpoints for Fountain Soda (Size/Flavor)

Admin Tools:
- addDrinkModifiers.cfm - Add size/flavor modifiers to drinks
- copyDrinksToBigDeans.cfm - Copy drink items between businesses
- debugDrinkStructure.cfm - Debug drink item hierarchy

Portal:
- Station assignment improvements with better drag-drop
- Enhanced debug task viewer

API Fixes:
- Application.cfm updated with new admin endpoint allowlist
- setLineItem.cfm formatting cleanup
- listMine.cfm task query fixes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:30:58 -08:00

431 lines
16 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>
<cfif BusinessID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
</cfif>
<cftry>
<!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
<cfset newSchemaActive = false>
<cftry>
<cfset qCheck = queryExecute(
"SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = ? AND ItemBusinessID > 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 ItemBusinessID, try Categories table first, fallback to parent Items --->
<!--- Check if Categories table has data for this business --->
<cfset hasCategoriesData = false>
<cftry>
<cfset qCatCheck = queryExecute(
"SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = ?",
[ { 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 ItemCategoryID --->
<!--- First, get category headers as virtual items --->
<cfset qCategories = queryExecute(
"
SELECT
CategoryID,
CategoryName,
CategorySortOrder
FROM Categories
WHERE CategoryBusinessID = ?
ORDER BY CategorySortOrder
",
[ { value = BusinessID, 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 --->
<cfset q = queryExecute(
"
SELECT
i.ItemID,
i.ItemCategoryID,
c.CategoryName,
i.ItemName,
i.ItemDescription,
i.ItemParentItemID,
i.ItemPrice,
i.ItemIsActive,
i.ItemIsCheckedByDefault,
i.ItemRequiresChildSelection,
i.ItemMaxNumSelectionReq,
i.ItemIsCollapsible,
i.ItemSortOrder,
i.ItemStationID,
s.StationName,
s.StationColor
FROM Items i
LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
WHERE i.ItemBusinessID = ?
AND i.ItemIsActive = 1
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID)
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID)
AND NOT (i.ItemParentItemID = 0 AND i.ItemCategoryID = 0 AND i.ItemPrice = 0)
ORDER BY COALESCE(c.CategorySortOrder, 999), i.ItemSortOrder, i.ItemID
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfelse>
<!--- Fallback: Derive categories from parent Items --->
<cfset q = queryExecute(
"
SELECT
i.ItemID,
CASE
WHEN i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0 THEN i.ItemID
ELSE COALESCE(
(SELECT cat.ItemID FROM Items cat
WHERE cat.ItemID = i.ItemParentItemID
AND cat.ItemParentItemID = 0
AND cat.ItemIsCollapsible = 0),
0
)
END as ItemCategoryID,
CASE
WHEN i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0 THEN i.ItemName
ELSE COALESCE(
(SELECT cat.ItemName FROM Items cat
WHERE cat.ItemID = i.ItemParentItemID
AND cat.ItemParentItemID = 0
AND cat.ItemIsCollapsible = 0),
''
)
END as CategoryName,
i.ItemName,
i.ItemDescription,
i.ItemParentItemID,
i.ItemPrice,
i.ItemIsActive,
i.ItemIsCheckedByDefault,
i.ItemRequiresChildSelection,
i.ItemMaxNumSelectionReq,
i.ItemIsCollapsible,
i.ItemSortOrder,
i.ItemStationID,
s.StationName,
s.StationColor
FROM Items i
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
WHERE i.ItemBusinessID = ?
AND i.ItemIsActive = 1
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID)
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID)
AND (
i.ItemParentItemID > 0
OR (i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0)
)
ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
</cfif>
<cfelse>
<!--- OLD SCHEMA: Use Categories table --->
<cfset q = queryExecute(
"
SELECT
i.ItemID,
i.ItemCategoryID,
c.CategoryName,
i.ItemName,
i.ItemDescription,
i.ItemParentItemID,
i.ItemPrice,
i.ItemIsActive,
i.ItemIsCheckedByDefault,
i.ItemRequiresChildSelection,
i.ItemMaxNumSelectionReq,
i.ItemIsCollapsible,
i.ItemSortOrder,
i.ItemStationID,
s.StationName,
s.StationColor
FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
WHERE c.CategoryBusinessID = ?
ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
</cfif>
<cfset rows = []>
<!--- For unified schema with Categories table, add category headers first --->
<cfif newSchemaActive AND isDefined("qCategories")>
<cfloop query="qCategories">
<!--- Add category as a virtual parent item --->
<!--- Use CategoryID as ItemID, and set ItemCategoryID to same value --->
<!--- Set ItemParentItemID to 0 to mark as root level --->
<cfset arrayAppend(rows, {
"ItemID": qCategories.CategoryID,
"ItemCategoryID": qCategories.CategoryID,
"ItemCategoryName": qCategories.CategoryName,
"ItemName": qCategories.CategoryName,
"ItemDescription": "",
"ItemParentItemID": 0,
"ItemPrice": 0,
"ItemIsActive": 1,
"ItemIsCheckedByDefault": 0,
"ItemRequiresChildSelection": 0,
"ItemMaxNumSelectionReq": 0,
"ItemIsCollapsible": 0,
"ItemSortOrder": qCategories.CategorySortOrder,
"ItemStationID": "",
"ItemStationName": "",
"ItemStationColor": ""
})>
</cfloop>
</cfif>
<!--- Build a set of category IDs for quick lookup --->
<cfset categoryIdSet = {}>
<cfif isDefined("qCategories")>
<cfloop query="qCategories">
<cfset categoryIdSet[qCategories.CategoryID] = 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.ItemParentItemID>
<cfif newSchemaActive AND isDefined("qCategories") AND q.ItemCategoryID GT 0>
<cfif q.ItemParentItemID EQ 0>
<!--- Item has no parent but has a category - link to category --->
<cfset effectiveParentID = q.ItemCategoryID>
<cfelseif structKeyExists(categoryIdSet, q.ItemParentItemID)>
<!--- Item's parent IS a category ID - this is correct, keep it --->
<cfset effectiveParentID = q.ItemParentItemID>
<cfelseif NOT structKeyExists(categoryIdSet, q.ItemParentItemID)>
<!--- Parent ID is an old-style category header - remap to CategoryID --->
<cfset effectiveParentID = q.ItemCategoryID>
</cfif>
</cfif>
<cfset arrayAppend(rows, {
"ItemID": q.ItemID,
"ItemCategoryID": q.ItemCategoryID,
"ItemCategoryName": len(trim(q.CategoryName)) ? q.CategoryName : "",
"ItemName": q.ItemName,
"ItemDescription": q.ItemDescription,
"ItemParentItemID": effectiveParentID,
"ItemPrice": q.ItemPrice,
"ItemIsActive": q.ItemIsActive,
"ItemIsCheckedByDefault": q.ItemIsCheckedByDefault,
"ItemRequiresChildSelection": q.ItemRequiresChildSelection,
"ItemMaxNumSelectionReq": q.ItemMaxNumSelectionReq,
"ItemIsCollapsible": q.ItemIsCollapsible,
"ItemSortOrder": q.ItemSortOrder,
"ItemStationID": len(trim(q.ItemStationID)) ? q.ItemStationID : "",
"ItemStationName": len(trim(q.StationName)) ? q.StationName : "",
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : ""
})>
</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 = queryExecute(
"
SELECT
tl.ItemID as MenuItemID,
tmpl.ItemID as TemplateItemID,
tmpl.ItemName as TemplateName,
tmpl.ItemDescription as TemplateDescription,
tmpl.ItemRequiresChildSelection as TemplateRequired,
tmpl.ItemMaxNumSelectionReq as TemplateMaxSelections,
tmpl.ItemIsCollapsible as TemplateIsCollapsible,
tl.SortOrder as TemplateSortOrder
FROM ItemTemplateLinks tl
INNER JOIN Items tmpl ON tmpl.ItemID = tl.TemplateItemID AND tmpl.ItemIsActive = 1
INNER JOIN Items menuItem ON menuItem.ItemID = tl.ItemID
WHERE menuItem.ItemBusinessID = ?
AND menuItem.ItemIsActive = 1
ORDER BY tl.ItemID, tl.SortOrder
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Get template options --->
<cfset qTemplateOptions = queryExecute(
"
SELECT DISTINCT
opt.ItemID as OptionItemID,
opt.ItemParentItemID as TemplateItemID,
opt.ItemName as OptionName,
opt.ItemDescription as OptionDescription,
opt.ItemPrice as OptionPrice,
opt.ItemIsCheckedByDefault as OptionIsDefault,
opt.ItemSortOrder as OptionSortOrder
FROM Items opt
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = opt.ItemParentItemID
INNER JOIN Items menuItem ON menuItem.ItemID = tl.ItemID
WHERE menuItem.ItemBusinessID = ?
AND menuItem.ItemIsActive = 1
AND opt.ItemIsActive = 1
ORDER BY opt.ItemParentItemID, opt.ItemSortOrder
",
[ { 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,
"ItemName": qTemplateOptions.OptionName,
"ItemDescription": qTemplateOptions.OptionDescription,
"ItemPrice": qTemplateOptions.OptionPrice,
"ItemIsCheckedByDefault": qTemplateOptions.OptionIsDefault,
"ItemSortOrder": 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,
"ItemCategoryID": 0,
"ItemCategoryName": "",
"ItemName": qTemplateLinks.TemplateName,
"ItemDescription": qTemplateLinks.TemplateDescription,
"ItemParentItemID": menuItemID,
"ItemPrice": 0,
"ItemIsActive": 1,
"ItemIsCheckedByDefault": 0,
"ItemRequiresChildSelection": qTemplateLinks.TemplateRequired,
"ItemMaxNumSelectionReq": qTemplateLinks.TemplateMaxSelections,
"ItemIsCollapsible": qTemplateLinks.TemplateIsCollapsible,
"ItemSortOrder": qTemplateLinks.TemplateSortOrder,
"ItemStationID": "",
"ItemStationName": "",
"ItemStationColor": ""
})>
<!--- 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,
"ItemCategoryID": 0,
"ItemCategoryName": "",
"ItemName": opt.ItemName,
"ItemDescription": opt.ItemDescription,
"ItemParentItemID": virtualTemplateID,
"ItemPrice": opt.ItemPrice,
"ItemIsActive": 1,
"ItemIsCheckedByDefault": opt.ItemIsCheckedByDefault,
"ItemRequiresChildSelection": 0,
"ItemMaxNumSelectionReq": 0,
"ItemIsCollapsible": 0,
"ItemSortOrder": opt.ItemSortOrder,
"ItemStationID": "",
"ItemStationName": "",
"ItemStationColor": ""
})>
</cfloop>
</cfif>
</cfloop>
</cfif>
<cfset apiAbort({
"OK": true,
"ERROR": "",
"Items": rows,
"COUNT": arrayLen(rows),
"SCHEMA": newSchemaActive ? "unified" : "legacy"
})>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "DB error loading items",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>