diff --git a/Application.cfm b/Application.cfm index 7a6ef49..bc4d830 100644 --- a/Application.cfm +++ b/Application.cfm @@ -5,7 +5,7 @@ sessiontimeout="#CreateTimeSpan(0,0,30,0)#" clientstorage="cookie"> - + diff --git a/api/Application.cfm b/api/Application.cfm index 2f98649..e3fc64f 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -29,6 +29,7 @@ clientmanagement="false" setclientcookies="false" datasource="payfrit" + showdebugoutput="false" > @@ -90,17 +91,38 @@ if (len(request._api_path)) { // Portal endpoints if (findNoCase("/api/portal/stats.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/portal/myBusinesses.cfm", request._api_path)) request._api_isPublic = true; // Menu builder endpoints if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/menu/updateStations.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true; - // Admin endpoints + // Admin endpoints (protected by localhost check in each file) if (findNoCase("/api/admin/resetTestData.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/debugTasks.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/testTaskInsert.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/debugBusinesses.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/setupStations.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/setupModifierTemplates.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/migrateModifierTemplates.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/deleteOrphanModifiers.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/fixShakeFlavors.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/eliminateCategories.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/cleanupCategories.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/deleteOrphans.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/switchBeacons.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/debugTemplateLinks.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/fixBigDeansCategories.cfm", request._api_path)) request._api_isPublic = true; + + // Setup/Import endpoints + if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/setup/analyzeMenu.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/setup/downloadImages.cfm", request._api_path)) request._api_isPublic = true; + + // Stations endpoints + if (findNoCase("/api/stations/list.cfm", request._api_path)) request._api_isPublic = true; // Stripe endpoints if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true; @@ -154,3 +176,4 @@ if (!request._api_isPublic) { } } + diff --git a/api/admin/checkUser.cfm b/api/admin/checkUser.cfm new file mode 100644 index 0000000..1f558e5 --- /dev/null +++ b/api/admin/checkUser.cfm @@ -0,0 +1,35 @@ + + + + + +phone = structKeyExists(url, "phone") ? url.phone : ""; +phone = reReplace(phone, "[^0-9]", "", "all"); + +if (!len(phone)) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "missing phone" })); + abort; +} + +q = queryExecute(" + SELECT UserID, UserFirstName, UserLastName, UserEmail, UserPhone, UserIsContactVerified + FROM Users + WHERE UserPhone = :phone OR UserEmail = :phone + LIMIT 1 +", { phone: phone }, { datasource: "payfrit" }); + +if (q.recordCount EQ 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "user_not_found", "phone": phone })); + abort; +} + +writeOutput(serializeJSON({ + "OK": true, + "UserID": q.UserID, + "FirstName": q.UserFirstName, + "LastName": q.UserLastName, + "Email": q.UserEmail, + "Phone": q.UserPhone, + "Verified": q.UserIsContactVerified +})); + diff --git a/api/admin/cleanupCategories.cfm b/api/admin/cleanupCategories.cfm new file mode 100644 index 0000000..2f73a50 --- /dev/null +++ b/api/admin/cleanupCategories.cfm @@ -0,0 +1,173 @@ + + + + + + + #serializeJSON({"OK": false, "ERROR": "admin_only"})# + + + + +/** + * Cleanup Categories - Final step after migration verification + * + * This script: + * 1. Verifies all Items have ItemBusinessID set + * 2. Finds orphan items (ParentID=0, no children, not in links) + * 3. Drops ItemCategoryID column + * 4. Drops ItemIsModifierTemplate column (derived from ItemTemplateLinks now) + * 5. Drops Categories table + * + * Query param: ?confirm=YES to actually execute (otherwise shows verification only) + */ + +response = { "OK": false, "verification": {}, "orphans": [], "steps": [] }; + +try { + confirm = structKeyExists(url, "confirm") && url.confirm == "YES"; + + // Verification Step 1: Check for items without BusinessID + qNoBusinessID = queryExecute(" + SELECT COUNT(*) as cnt FROM Items + WHERE ItemBusinessID IS NULL OR ItemBusinessID = 0 + ", {}, { datasource: "payfrit" }); + + response.verification["itemsWithoutBusinessID"] = qNoBusinessID.cnt; + + // Verification Step 2: Check that all categories were converted + qCategories = queryExecute(" + SELECT COUNT(*) as cnt FROM Categories + ", {}, { datasource: "payfrit" }); + + response.verification["categoriesRemaining"] = qCategories.cnt; + + // Verification Step 3: Check category Items exist (ParentID=0 with children) + qCategoryItems = queryExecute(" + SELECT COUNT(DISTINCT p.ItemID) as cnt + FROM Items p + INNER JOIN Items c ON c.ItemParentItemID = p.ItemID + WHERE p.ItemParentItemID = 0 + AND p.ItemBusinessID > 0 + AND NOT EXISTS ( + SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = p.ItemID + ) + ", {}, { datasource: "payfrit" }); + + response.verification["categoryItemsCreated"] = qCategoryItems.cnt; + + // Verification Step 4: Check templates exist (in ItemTemplateLinks) + qTemplates = queryExecute(" + SELECT COUNT(DISTINCT tl.TemplateItemID) as cnt + FROM ItemTemplateLinks tl + INNER JOIN Items t ON t.ItemID = tl.TemplateItemID + ", {}, { datasource: "payfrit" }); + + response.verification["templatesInLinks"] = qTemplates.cnt; + + // Verification Step 5: Find orphans at ParentID=0 + // Orphan = ParentID=0, no children pointing to it, not in ItemTemplateLinks + qOrphans = queryExecute(" + SELECT i.ItemID, i.ItemName, i.ItemBusinessID + FROM Items i + WHERE i.ItemParentItemID = 0 + AND NOT EXISTS ( + SELECT 1 FROM Items child WHERE child.ItemParentItemID = i.ItemID + ) + AND NOT EXISTS ( + SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID + ) + ORDER BY i.ItemBusinessID, i.ItemName + ", {}, { datasource: "payfrit" }); + + response.verification["orphanCount"] = qOrphans.recordCount; + + for (orphan in qOrphans) { + arrayAppend(response.orphans, { + "ItemID": orphan.ItemID, + "ItemName": orphan.ItemName, + "BusinessID": orphan.ItemBusinessID + }); + } + + // Summary + safeToCleanup = (qNoBusinessID.cnt == 0); + response.verification["safeToCleanup"] = safeToCleanup; + + if (!safeToCleanup) { + arrayAppend(response.steps, "VERIFICATION FAILED - Cannot cleanup yet"); + arrayAppend(response.steps, "- " & qNoBusinessID.cnt & " items still missing ItemBusinessID"); + response["OK"] = false; + writeOutput(serializeJSON(response)); + abort; + } + + if (qOrphans.recordCount > 0) { + arrayAppend(response.steps, "WARNING: " & qOrphans.recordCount & " orphan items found (see orphans array)"); + arrayAppend(response.steps, "These will NOT be deleted - review and handle manually if needed"); + } + + arrayAppend(response.steps, "Verification passed - safe to cleanup"); + + if (!confirm) { + arrayAppend(response.steps, "Add ?confirm=YES to execute cleanup"); + response["OK"] = true; + writeOutput(serializeJSON(response)); + abort; + } + + // Execute cleanup + arrayAppend(response.steps, "Executing cleanup..."); + + // Step 1: Drop ItemCategoryID column + try { + queryExecute(" + ALTER TABLE Items DROP COLUMN ItemCategoryID + ", {}, { datasource: "payfrit" }); + arrayAppend(response.steps, "Dropped ItemCategoryID column from Items"); + } catch (any e) { + if (findNoCase("check that column", e.message) || findNoCase("Unknown column", e.message)) { + arrayAppend(response.steps, "ItemCategoryID column already dropped"); + } else { + arrayAppend(response.steps, "Warning dropping ItemCategoryID: " & e.message); + } + } + + // Step 2: Drop ItemIsModifierTemplate column (now derived from ItemTemplateLinks) + try { + queryExecute(" + ALTER TABLE Items DROP COLUMN ItemIsModifierTemplate + ", {}, { datasource: "payfrit" }); + arrayAppend(response.steps, "Dropped ItemIsModifierTemplate column from Items"); + } catch (any e) { + if (findNoCase("check that column", e.message) || findNoCase("Unknown column", e.message)) { + arrayAppend(response.steps, "ItemIsModifierTemplate column already dropped"); + } else { + arrayAppend(response.steps, "Warning dropping ItemIsModifierTemplate: " & e.message); + } + } + + // Step 3: Drop Categories table + try { + queryExecute(" + DROP TABLE Categories + ", {}, { datasource: "payfrit" }); + arrayAppend(response.steps, "Dropped Categories table"); + } catch (any e) { + if (findNoCase("Unknown table", e.message)) { + arrayAppend(response.steps, "Categories table already dropped"); + } else { + arrayAppend(response.steps, "Warning dropping Categories: " & e.message); + } + } + + response["OK"] = true; + arrayAppend(response.steps, "CLEANUP COMPLETE - Schema simplified"); + +} catch (any e) { + response["ERROR"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/debugBigDeansDeactivated.cfm b/api/admin/debugBigDeansDeactivated.cfm new file mode 100644 index 0000000..02881ce --- /dev/null +++ b/api/admin/debugBigDeansDeactivated.cfm @@ -0,0 +1,38 @@ + + + + + +bizId = 27; + +// These are the items that were deactivated by fixBigDeansCategories.cfm +deactivatedIds = [11177, 11180, 11183, 11186, 11190, 11193, 11196, 11199, 11204, 11212, 11220, 11259]; + +qDeactivated = queryExecute(" + SELECT i.ItemID, i.ItemName, i.ItemParentItemID, i.ItemIsActive, i.ItemIsCollapsible, + (SELECT COUNT(*) FROM Items c WHERE c.ItemParentItemID = i.ItemID) as ChildCount, + (SELECT GROUP_CONCAT(c.ItemName) FROM Items c WHERE c.ItemParentItemID = i.ItemID) as Children + FROM Items i + WHERE i.ItemID IN (:ids) + ORDER BY i.ItemID +", { ids: { value: arrayToList(deactivatedIds), list: true } }, { datasource: "payfrit" }); + +items = []; +for (row in qDeactivated) { + arrayAppend(items, { + "ItemID": row.ItemID, + "ItemName": row.ItemName, + "ParentID": row.ItemParentItemID, + "IsActive": row.ItemIsActive, + "IsCollapsible": row.ItemIsCollapsible, + "ChildCount": row.ChildCount, + "Children": row.Children + }); +} + +writeOutput(serializeJSON({ + "OK": true, + "Message": "These items were deactivated thinking they were fake categories", + "DeactivatedItems": items +})); + diff --git a/api/admin/debugBigDeansLinks.cfm b/api/admin/debugBigDeansLinks.cfm new file mode 100644 index 0000000..0ba3294 --- /dev/null +++ b/api/admin/debugBigDeansLinks.cfm @@ -0,0 +1,70 @@ + + + + + +bizId = 27; + +// Get all template links for Big Dean's with item names +qLinks = queryExecute(" + SELECT + tl.ItemID as MenuItemID, + mi.ItemName as MenuItemName, + mi.ItemParentItemID, + tl.TemplateItemID, + t.ItemName as TemplateName, + tl.SortOrder + FROM ItemTemplateLinks tl + JOIN Items mi ON mi.ItemID = tl.ItemID + JOIN Items t ON t.ItemID = tl.TemplateItemID + WHERE mi.ItemBusinessID = :bizId + ORDER BY mi.ItemParentItemID, mi.ItemName, tl.SortOrder +", { bizId: bizId }, { datasource: "payfrit" }); + +links = []; +for (row in qLinks) { + arrayAppend(links, { + "MenuItemID": row.MenuItemID, + "MenuItemName": row.MenuItemName, + "ParentItemID": row.ItemParentItemID, + "TemplateItemID": row.TemplateItemID, + "TemplateName": row.TemplateName + }); +} + +// Get burgers specifically (parent = 11271) +qBurgers = queryExecute(" + SELECT ItemID, ItemName FROM Items + WHERE ItemBusinessID = :bizId AND ItemParentItemID = 11271 AND ItemIsActive = 1 + ORDER BY ItemSortOrder +", { bizId: bizId }, { datasource: "payfrit" }); + +burgers = []; +for (row in qBurgers) { + // Get templates for this burger + qBurgerTemplates = queryExecute(" + SELECT tl.TemplateItemID, t.ItemName as TemplateName + FROM ItemTemplateLinks tl + JOIN Items t ON t.ItemID = tl.TemplateItemID + WHERE tl.ItemID = :itemId + ", { itemId: row.ItemID }, { datasource: "payfrit" }); + + templates = []; + for (t in qBurgerTemplates) { + arrayAppend(templates, t.TemplateName); + } + + arrayAppend(burgers, { + "ItemID": row.ItemID, + "ItemName": row.ItemName, + "Templates": templates + }); +} + +writeOutput(serializeJSON({ + "OK": true, + "totalLinks": qLinks.recordCount, + "links": links, + "burgers": burgers +})); + diff --git a/api/admin/debugBigDeansTemplates.cfm b/api/admin/debugBigDeansTemplates.cfm new file mode 100644 index 0000000..b7011bb --- /dev/null +++ b/api/admin/debugBigDeansTemplates.cfm @@ -0,0 +1,64 @@ + + + + + +bizId = 27; + +// Get all template links for Big Dean's +qLinks = queryExecute(" + SELECT + tl.ItemID as MenuItemID, + mi.ItemName as MenuItemName, + mi.ItemParentItemID as MenuItemParentID, + tl.TemplateItemID, + t.ItemName as TemplateName, + t.ItemIsActive as TemplateActive, + tl.SortOrder + FROM ItemTemplateLinks tl + JOIN Items mi ON mi.ItemID = tl.ItemID + JOIN Items t ON t.ItemID = tl.TemplateItemID + WHERE mi.ItemBusinessID = :bizId + ORDER BY mi.ItemName, tl.SortOrder +", { bizId: bizId }, { datasource: "payfrit" }); + +links = []; +for (row in qLinks) { + arrayAppend(links, { + "MenuItemID": row.MenuItemID, + "MenuItemName": row.MenuItemName, + "MenuItemParentID": row.MenuItemParentID, + "TemplateItemID": row.TemplateItemID, + "TemplateName": row.TemplateName, + "TemplateActive": row.TemplateActive, + "SortOrder": row.SortOrder + }); +} + +// Get all templates that exist for this business +qTemplates = queryExecute(" + SELECT ItemID, ItemName, ItemIsActive, ItemParentItemID + FROM Items + WHERE ItemBusinessID = :bizId + AND ItemIsCollapsible = 1 + ORDER BY ItemName +", { bizId: bizId }, { datasource: "payfrit" }); + +templates = []; +for (row in qTemplates) { + arrayAppend(templates, { + "ItemID": row.ItemID, + "ItemName": row.ItemName, + "IsActive": row.ItemIsActive, + "ParentID": row.ItemParentItemID + }); +} + +writeOutput(serializeJSON({ + "OK": true, + "TemplateLinksCount": arrayLen(links), + "TemplateLinks": links, + "TemplatesCount": arrayLen(templates), + "Templates": templates +})); + diff --git a/api/admin/debugBigDeansTemplates2.cfm b/api/admin/debugBigDeansTemplates2.cfm new file mode 100644 index 0000000..b40b05e --- /dev/null +++ b/api/admin/debugBigDeansTemplates2.cfm @@ -0,0 +1,79 @@ + + + + + +bizId = 27; + +// Check the template items themselves (IDs from ItemTemplateLinks) +templateIds = "11267, 11251, 11246, 11224, 11233, 11230, 11240, 11243, 11237, 11227"; + +qTemplates = queryExecute(" + SELECT ItemID, ItemName, ItemIsCollapsible, ItemIsActive, ItemParentItemID, ItemBusinessID + FROM Items + WHERE ItemID IN (#templateIds#) + ORDER BY ItemName +", {}, { datasource: "payfrit" }); + +templates = []; +for (row in qTemplates) { + arrayAppend(templates, { + "ItemID": row.ItemID, + "ItemName": row.ItemName, + "IsCollapsible": row.ItemIsCollapsible, + "IsActive": row.ItemIsActive, + "ParentID": row.ItemParentItemID, + "BusinessID": row.ItemBusinessID + }); +} + +// Also check what other templates might exist for burgers +// Look for items that are in ItemTemplateLinks but NOT linked to burgers +qMissingTemplates = queryExecute(" + SELECT DISTINCT t.ItemID, t.ItemName, t.ItemIsCollapsible, t.ItemIsActive + FROM Items t + WHERE t.ItemBusinessID = :bizId + AND t.ItemParentItemID = 0 + AND t.ItemID NOT IN ( + SELECT i.ItemID FROM Items i WHERE i.ItemBusinessID = :bizId AND i.ItemCategoryID > 0 + ) + AND EXISTS (SELECT 1 FROM Items child WHERE child.ItemParentItemID = t.ItemID) + ORDER BY t.ItemName +", { bizId: bizId }, { datasource: "payfrit" }); + +potentialTemplates = []; +for (row in qMissingTemplates) { + arrayAppend(potentialTemplates, { + "ItemID": row.ItemID, + "ItemName": row.ItemName, + "IsCollapsible": row.ItemIsCollapsible, + "IsActive": row.ItemIsActive + }); +} + +// What templates SHOULD burgers have? Let's see all templates used by ANY item +qAllTemplateUsage = queryExecute(" + SELECT t.ItemID, t.ItemName, COUNT(tl.ItemID) as UsageCount + FROM ItemTemplateLinks tl + JOIN Items t ON t.ItemID = tl.TemplateItemID + JOIN Items mi ON mi.ItemID = tl.ItemID AND mi.ItemBusinessID = :bizId + GROUP BY t.ItemID, t.ItemName + ORDER BY t.ItemName +", { bizId: bizId }, { datasource: "payfrit" }); + +allTemplates = []; +for (row in qAllTemplateUsage) { + arrayAppend(allTemplates, { + "TemplateID": row.ItemID, + "TemplateName": row.ItemName, + "UsageCount": row.UsageCount + }); +} + +writeOutput(serializeJSON({ + "OK": true, + "TemplatesInLinks": templates, + "PotentialTemplates": potentialTemplates, + "AllTemplateUsage": allTemplates +})); + diff --git a/api/admin/debugBigDeansTemplates3.cfm b/api/admin/debugBigDeansTemplates3.cfm new file mode 100644 index 0000000..a5526b0 --- /dev/null +++ b/api/admin/debugBigDeansTemplates3.cfm @@ -0,0 +1,75 @@ + + + + + +bizId = 27; + +// Get the template items themselves +qTemplates = queryExecute(" + SELECT ItemID, ItemName, ItemIsCollapsible, ItemIsActive, ItemParentItemID, ItemBusinessID + FROM Items + WHERE ItemID IN (11267, 11251, 11246, 11224, 11233, 11230, 11240, 11243, 11237, 11227) + ORDER BY ItemName +", {}, { datasource: "payfrit" }); + +templates = []; +for (row in qTemplates) { + arrayAppend(templates, { + "ItemID": row.ItemID, + "ItemName": row.ItemName, + "IsCollapsible": row.ItemIsCollapsible, + "IsActive": row.ItemIsActive, + "ParentID": row.ItemParentItemID, + "BusinessID": row.ItemBusinessID + }); +} + +// What templates are used by burgers vs all items? +qBurgerLinks = queryExecute(" + SELECT mi.ItemID, mi.ItemName, GROUP_CONCAT(t.ItemName ORDER BY tl.SortOrder) as Templates + FROM Items mi + JOIN ItemTemplateLinks tl ON tl.ItemID = mi.ItemID + JOIN Items t ON t.ItemID = tl.TemplateItemID + WHERE mi.ItemBusinessID = :bizId + AND mi.ItemParentItemID = 11271 + GROUP BY mi.ItemID, mi.ItemName + ORDER BY mi.ItemName +", { bizId: bizId }, { datasource: "payfrit" }); + +burgerLinks = []; +for (row in qBurgerLinks) { + arrayAppend(burgerLinks, { + "ItemID": row.ItemID, + "ItemName": row.ItemName, + "Templates": row.Templates + }); +} + +// Also check: are there templates that SHOULD be linked to burgers? +// (e.g., Add Cheese, etc.) +qCheeseTemplate = queryExecute(" + SELECT ItemID, ItemName, ItemParentItemID, ItemIsActive + FROM Items + WHERE ItemBusinessID = :bizId + AND ItemName LIKE '%Cheese%' + ORDER BY ItemName +", { bizId: bizId }, { datasource: "payfrit" }); + +cheeseItems = []; +for (row in qCheeseTemplate) { + arrayAppend(cheeseItems, { + "ItemID": row.ItemID, + "ItemName": row.ItemName, + "ParentID": row.ItemParentItemID, + "IsActive": row.ItemIsActive + }); +} + +writeOutput(serializeJSON({ + "OK": true, + "TemplatesUsed": templates, + "BurgerTemplateLinks": burgerLinks, + "CheeseRelatedItems": cheeseItems +})); + diff --git a/api/admin/debugTemplateLinks.cfm b/api/admin/debugTemplateLinks.cfm new file mode 100644 index 0000000..c19da94 --- /dev/null +++ b/api/admin/debugTemplateLinks.cfm @@ -0,0 +1,47 @@ + + + + + +// Check ItemTemplateLinks for Big Dean's (BusinessID 27) +bizId = 27; + +// Count total links +qCount = queryExecute("SELECT COUNT(*) as cnt FROM ItemTemplateLinks", {}, { datasource: "payfrit" }); + +// Get template item IDs for this business +qTemplates = queryExecute(" + SELECT DISTINCT tl.TemplateItemID, i.ItemName + FROM ItemTemplateLinks tl + JOIN Items i ON i.ItemID = tl.TemplateItemID + WHERE i.ItemBusinessID = :bizId +", { bizId: bizId }, { datasource: "payfrit" }); + +templates = []; +for (row in qTemplates) { + arrayAppend(templates, { "TemplateItemID": row.TemplateItemID, "ItemName": row.ItemName }); +} + +// Get items that should be categories (ParentItemID=0, not templates) +qCategories = queryExecute(" + SELECT i.ItemID, i.ItemName, i.ItemParentItemID, i.ItemIsCollapsible + FROM Items i + WHERE i.ItemBusinessID = :bizId + AND i.ItemParentItemID = 0 + AND i.ItemIsCollapsible = 0 + AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID) + ORDER BY i.ItemSortOrder +", { bizId: bizId }, { datasource: "payfrit" }); + +categories = []; +for (row in qCategories) { + arrayAppend(categories, { "ItemID": row.ItemID, "ItemName": row.ItemName }); +} + +writeOutput(serializeJSON({ + "OK": true, + "totalTemplateLinks": qCount.cnt, + "templatesForBusiness": templates, + "categoriesForBusiness": categories +})); + diff --git a/api/admin/deleteOrphanModifiers.cfm b/api/admin/deleteOrphanModifiers.cfm new file mode 100644 index 0000000..ae2ac8b --- /dev/null +++ b/api/admin/deleteOrphanModifiers.cfm @@ -0,0 +1,85 @@ + + + + + + + #serializeJSON({"OK": false, "ERROR": "admin_only"})# + + + + +/** + * Delete Orphan Modifiers for In and Out Burger (BusinessID=17) + * + * This script deletes duplicate modifier items that are no longer needed + * because we now use ItemTemplateLinks. + * + * The orphan items are level-1 modifiers (direct children of parent items) + * that have been replaced by template links. + */ + +response = { "OK": false, "deleted": [], "errors": [] }; + +try { + businessID = 17; // In and Out Burger + + // Get all level-1 modifiers that are NOT templates + // These are the duplicates we want to delete + qOrphans = queryExecute(" + SELECT + m.ItemID, + m.ItemName, + m.ItemParentItemID, + p.ItemName as ParentName + FROM Items m + INNER JOIN Items p ON p.ItemID = m.ItemParentItemID + INNER JOIN Categories c ON c.CategoryID = p.ItemCategoryID + WHERE c.CategoryBusinessID = :businessID + AND m.ItemParentItemID > 0 + AND p.ItemParentItemID = 0 + AND (m.ItemIsModifierTemplate IS NULL OR m.ItemIsModifierTemplate = 0) + AND m.ItemIsActive = 1 + ORDER BY m.ItemName + ", { businessID: businessID }, { datasource: "payfrit" }); + + arrayAppend(response.deleted, "Found " & qOrphans.recordCount & " orphan modifiers to delete"); + + // First, delete all children of orphan items (level 3+) + for (orphan in qOrphans) { + try { + // Delete children of this orphan (options within the modifier group) + qDeleteChildren = queryExecute(" + DELETE FROM Items WHERE ItemParentItemID = :orphanID + ", { orphanID: orphan.ItemID }, { datasource: "payfrit" }); + + // Delete the orphan itself + qDeleteOrphan = queryExecute(" + DELETE FROM Items WHERE ItemID = :orphanID + ", { orphanID: orphan.ItemID }, { datasource: "payfrit" }); + + arrayAppend(response.deleted, { + "ItemID": orphan.ItemID, + "ItemName": orphan.ItemName, + "WasUnder": orphan.ParentName + }); + } catch (any deleteErr) { + arrayAppend(response.errors, { + "ItemID": orphan.ItemID, + "ItemName": orphan.ItemName, + "Error": deleteErr.message + }); + } + } + + response["deletedCount"] = arrayLen(response.deleted) - 1; // Subtract the "Found X" message + response["errorCount"] = arrayLen(response.errors); + response["OK"] = true; + +} catch (any e) { + response["ERROR"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/deleteOrphans.cfm b/api/admin/deleteOrphans.cfm new file mode 100644 index 0000000..a7f6ece --- /dev/null +++ b/api/admin/deleteOrphans.cfm @@ -0,0 +1,60 @@ + + + + + + + #serializeJSON({"OK": false, "ERROR": "admin_only"})# + + + + +/** + * Delete orphan Items at ParentID=0 + * Orphan = ParentID=0, no children, not in ItemTemplateLinks + */ + +response = { "OK": false, "deleted": 0, "orphans": [] }; + +try { + // Find orphans + qOrphans = queryExecute(" + SELECT i.ItemID, i.ItemName, i.ItemBusinessID + FROM Items i + WHERE i.ItemParentItemID = 0 + AND NOT EXISTS ( + SELECT 1 FROM Items child WHERE child.ItemParentItemID = i.ItemID + ) + AND NOT EXISTS ( + SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID + ) + ORDER BY i.ItemBusinessID, i.ItemName + ", {}, { datasource: "payfrit" }); + + orphanIDs = []; + for (orphan in qOrphans) { + arrayAppend(response.orphans, { + "ItemID": orphan.ItemID, + "ItemName": orphan.ItemName, + "BusinessID": orphan.ItemBusinessID + }); + arrayAppend(orphanIDs, orphan.ItemID); + } + + // Delete them by ID list + if (arrayLen(orphanIDs) > 0) { + queryExecute(" + DELETE FROM Items WHERE ItemID IN (#arrayToList(orphanIDs)#) + ", {}, { datasource: "payfrit" }); + } + + response["deleted"] = arrayLen(orphanIDs); + response["OK"] = true; + +} catch (any e) { + response["ERROR"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/describeItems.cfm b/api/admin/describeItems.cfm new file mode 100644 index 0000000..740e814 --- /dev/null +++ b/api/admin/describeItems.cfm @@ -0,0 +1,19 @@ + + + + + +qDesc = queryExecute("SHOW COLUMNS FROM Items", {}, { datasource: "payfrit" }); + +cols = []; +for (row in qDesc) { + arrayAppend(cols, { + "Field": row.Field, + "Type": row.Type, + "Null": row.Null, + "Default": row.Default + }); +} + +writeOutput(serializeJSON({ "OK": true, "Columns": cols })); + diff --git a/api/admin/eliminateCategories.cfm b/api/admin/eliminateCategories.cfm new file mode 100644 index 0000000..152678d --- /dev/null +++ b/api/admin/eliminateCategories.cfm @@ -0,0 +1,252 @@ + + + + + + + + #serializeJSON({"OK": false, "ERROR": "admin_only"})# + + + + +/** + * Eliminate Categories Table - Schema Migration + * + * Final unified schema: + * - Everything is an Item with ItemBusinessID + * - ParentID=0 items are either categories or templates (derived from usage) + * - Categories = items at ParentID=0 that have menu items as children + * - Templates = items at ParentID=0 that appear in ItemTemplateLinks + * - Orphans = ParentID=0 items that are neither (cleanup candidates) + * + * Steps: + * 1. Add ItemBusinessID column to Items + * 2. For each Category: create Item, re-parent menu items, set BusinessID + * 3. Set ItemBusinessID on templates based on linked items + * + * Query param: ?dryRun=1 to preview without making changes + */ + +response = { "OK": false, "steps": [], "migrations": [], "dryRun": false }; + +try { + dryRun = structKeyExists(url, "dryRun") && url.dryRun == 1; + response["dryRun"] = dryRun; + + // Step 1: Add ItemBusinessID column if it doesn't exist + try { + if (!dryRun) { + queryExecute(" + ALTER TABLE Items ADD COLUMN ItemBusinessID INT DEFAULT 0 AFTER ItemID + ", {}, { datasource: "payfrit" }); + } + arrayAppend(response.steps, "Added ItemBusinessID column to Items table"); + } catch (any e) { + if (findNoCase("Duplicate column", e.message)) { + arrayAppend(response.steps, "ItemBusinessID column already exists"); + } else { + throw(e); + } + } + + // Step 2: Add index on ItemBusinessID + try { + if (!dryRun) { + queryExecute(" + CREATE INDEX idx_item_business ON Items (ItemBusinessID) + ", {}, { datasource: "payfrit" }); + } + arrayAppend(response.steps, "Added index on ItemBusinessID"); + } catch (any e) { + if (findNoCase("Duplicate key name", e.message)) { + arrayAppend(response.steps, "Index idx_item_business already exists"); + } else { + arrayAppend(response.steps, "Index warning: " & e.message); + } + } + + // Step 3: Drop foreign key constraint on ItemCategoryID if it exists + try { + if (!dryRun) { + queryExecute(" + ALTER TABLE Items DROP FOREIGN KEY Items_ibfk_1 + ", {}, { datasource: "payfrit" }); + } + arrayAppend(response.steps, "Dropped foreign key constraint Items_ibfk_1"); + } catch (any e) { + if (findNoCase("check that column", e.message) || findNoCase("Can't DROP", e.message)) { + arrayAppend(response.steps, "Foreign key Items_ibfk_1 already dropped or doesn't exist"); + } else { + arrayAppend(response.steps, "FK warning: " & e.message); + } + } + + // Step 4: Get all Categories + qCategories = queryExecute(" + SELECT CategoryID, CategoryBusinessID, CategoryName + FROM Categories + ORDER BY CategoryBusinessID, CategoryName + ", {}, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Found " & qCategories.recordCount & " categories to migrate"); + + // Step 4: Migrate each category + for (cat in qCategories) { + migration = { + "oldCategoryID": cat.CategoryID, + "categoryName": cat.CategoryName, + "businessID": cat.CategoryBusinessID, + "newItemID": 0, + "itemsUpdated": 0 + }; + + if (!dryRun) { + // Create new Item for this category (ParentID=0, no template flag needed) + // Note: ItemCategoryID set to 0 temporarily until we drop that column + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, + ItemCategoryID, + ItemName, + ItemDescription, + ItemParentItemID, + ItemPrice, + ItemIsActive, + ItemIsCheckedByDefault, + ItemRequiresChildSelection, + ItemSortOrder, + ItemAddedOn + ) VALUES ( + :businessID, + 0, + :categoryName, + '', + 0, + 0, + 1, + 0, + 0, + 0, + NOW() + ) + ", { + businessID: cat.CategoryBusinessID, + categoryName: cat.CategoryName + }, { datasource: "payfrit" }); + + // Get the new Item ID + qNewItem = queryExecute(" + SELECT ItemID FROM Items + WHERE ItemBusinessID = :businessID + AND ItemName = :categoryName + AND ItemParentItemID = 0 + ORDER BY ItemID DESC + LIMIT 1 + ", { + businessID: cat.CategoryBusinessID, + categoryName: cat.CategoryName + }, { datasource: "payfrit" }); + + newItemID = qNewItem.ItemID; + migration["newItemID"] = newItemID; + + // Update menu items in this category: + // - Set ItemParentItemID = newItemID (for top-level items only) + // - Set ItemBusinessID = businessID (for all items) + queryExecute(" + UPDATE Items + SET ItemBusinessID = :businessID, + ItemParentItemID = :newItemID + WHERE ItemCategoryID = :categoryID + AND ItemParentItemID = 0 + ", { + businessID: cat.CategoryBusinessID, + newItemID: newItemID, + categoryID: cat.CategoryID + }, { datasource: "payfrit" }); + + // Set ItemBusinessID on ALL items in this category (including nested) + queryExecute(" + UPDATE Items + SET ItemBusinessID = :businessID + WHERE ItemCategoryID = :categoryID + AND (ItemBusinessID IS NULL OR ItemBusinessID = 0) + ", { + businessID: cat.CategoryBusinessID, + categoryID: cat.CategoryID + }, { datasource: "payfrit" }); + + // Count how many were updated + qCount = queryExecute(" + SELECT COUNT(*) as cnt FROM Items + WHERE ItemParentItemID = :newItemID + ", { newItemID: newItemID }, { datasource: "payfrit" }); + + migration["itemsUpdated"] = qCount.cnt; + } else { + // Dry run - count what would be updated + qCount = queryExecute(" + SELECT COUNT(*) as cnt FROM Items + WHERE ItemCategoryID = :categoryID + AND ItemParentItemID = 0 + ", { categoryID: cat.CategoryID }, { datasource: "payfrit" }); + + migration["itemsToUpdate"] = qCount.cnt; + } + + arrayAppend(response.migrations, migration); + } + + // Step 5: Set ItemBusinessID for templates (items in ItemTemplateLinks) + // Templates get their BusinessID from the items they're linked to + if (!dryRun) { + queryExecute(" + UPDATE Items t + INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID + INNER JOIN Items i ON i.ItemID = tl.ItemID + SET t.ItemBusinessID = i.ItemBusinessID + WHERE (t.ItemBusinessID IS NULL OR t.ItemBusinessID = 0) + AND i.ItemBusinessID > 0 + ", {}, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Set ItemBusinessID on templates from linked items"); + + // Set ItemBusinessID on template children (options) + queryExecute(" + UPDATE Items c + INNER JOIN Items t ON t.ItemID = c.ItemParentItemID + SET c.ItemBusinessID = t.ItemBusinessID + WHERE t.ItemBusinessID > 0 + AND (c.ItemBusinessID IS NULL OR c.ItemBusinessID = 0) + ", {}, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Set ItemBusinessID on template children"); + + // Make sure templates have ParentID=0 (they live at top level) + queryExecute(" + UPDATE Items t + INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID + SET t.ItemParentItemID = 0 + WHERE t.ItemParentItemID != 0 + ", {}, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Ensured templates have ParentItemID=0"); + } + + response["OK"] = true; + + if (dryRun) { + arrayAppend(response.steps, "DRY RUN COMPLETE - No changes made. Run without ?dryRun=1 to execute."); + } else { + arrayAppend(response.steps, "MIGRATION COMPLETE - Categories converted to Items"); + arrayAppend(response.steps, "Run cleanupCategories.cfm to drop old columns/tables after verification"); + } + +} catch (any e) { + response["ERROR"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/fixBigDeansCategories.cfm b/api/admin/fixBigDeansCategories.cfm new file mode 100644 index 0000000..590d392 --- /dev/null +++ b/api/admin/fixBigDeansCategories.cfm @@ -0,0 +1,50 @@ + + + + + +// Fix Big Dean's - remove the fake "category" items that are actually modifier templates +// The real categories start at ItemID 11271 (World Famous Burgers) +bizId = 27; + +// These are the modifier template parent items that got created incorrectly as root items +// They should NOT be categories - they're modifier group headers +fakeCategories = [11177, 11180, 11183, 11186, 11190, 11193, 11196, 11199, 11204, 11212, 11220, 11259]; + +// Deactivate these items (or we could delete them, but deactivate is safer) +for (itemId in fakeCategories) { + queryExecute(" + UPDATE Items SET ItemIsActive = 0 WHERE ItemID = :itemId AND ItemBusinessID = :bizId + ", { itemId: itemId, bizId: bizId }, { datasource: "payfrit" }); +} + +// Also deactivate their children (modifier options that belong to these fake parents) +for (itemId in fakeCategories) { + queryExecute(" + UPDATE Items SET ItemIsActive = 0 WHERE ItemParentItemID = :itemId AND ItemBusinessID = :bizId + ", { itemId: itemId, bizId: bizId }, { datasource: "payfrit" }); +} + +// Now verify what categories remain +qCategories = queryExecute(" + SELECT i.ItemID, i.ItemName + FROM Items i + WHERE i.ItemBusinessID = :bizId + AND i.ItemParentItemID = 0 + AND i.ItemIsActive = 1 + AND i.ItemIsCollapsible = 0 + AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID) + ORDER BY i.ItemSortOrder +", { bizId: bizId }, { datasource: "payfrit" }); + +categories = []; +for (row in qCategories) { + arrayAppend(categories, { "ItemID": row.ItemID, "ItemName": row.ItemName }); +} + +writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Deactivated #arrayLen(fakeCategories)# fake category items and their children", + "remainingCategories": categories +})); + diff --git a/api/admin/fixBigDeansModifiers.cfm b/api/admin/fixBigDeansModifiers.cfm new file mode 100644 index 0000000..7c64056 --- /dev/null +++ b/api/admin/fixBigDeansModifiers.cfm @@ -0,0 +1,84 @@ + + + + + +bizId = 27; +dryRun = structKeyExists(url, "dryRun") ? true : false; + +// These modifier templates were incorrectly deactivated +// We need to reactivate and link them to burgers +modifierTemplates = { + // For burgers: need "Extras" with Add Cheese option + "11196": { name: "Extras", children: "Add Cheese, Add Onions", linkToBurgers: true }, + // There's already an active "Onions" (11267), so we don't need 11220 +}; + +// Burger items that need modifiers +burgerIds = [11286, 11287, 11288, 11289, 11290]; // Big Dean's, Single w/Cheese, Single, Beyond, Garden + +actions = []; + +// First, let's see what templates already exist and are active for burgers +qExistingLinks = queryExecute(" + SELECT tl.ItemID as MenuItemID, tl.TemplateItemID, t.ItemName as TemplateName + FROM ItemTemplateLinks tl + JOIN Items t ON t.ItemID = tl.TemplateItemID + WHERE tl.ItemID IN (:burgerIds) +", { burgerIds: { value: arrayToList(burgerIds), list: true } }, { datasource: "payfrit" }); + +for (row in qExistingLinks) { + arrayAppend(actions, { action: "EXISTS", menuItemID: row.MenuItemID, templateID: row.TemplateItemID, templateName: row.TemplateName }); +} + +// Reactivate template 11196 (Extras with Add Cheese) +if (!dryRun) { + queryExecute("UPDATE Items SET ItemIsActive = 1 WHERE ItemID = 11196", {}, { datasource: "payfrit" }); + queryExecute("UPDATE Items SET ItemIsActive = 1 WHERE ItemParentItemID = 11196", {}, { datasource: "payfrit" }); +} +arrayAppend(actions, { action: dryRun ? "WOULD_REACTIVATE" : "REACTIVATED", itemID: 11196, name: "Extras (Add Cheese, Add Onions)" }); + +// Link template 11196 to all burgers +for (burgerId in burgerIds) { + // Check if link already exists + qCheck = queryExecute(" + SELECT COUNT(*) as cnt FROM ItemTemplateLinks WHERE ItemID = :burgerId AND TemplateItemID = 11196 + ", { burgerId: burgerId }, { datasource: "payfrit" }); + + if (qCheck.cnt EQ 0) { + if (!dryRun) { + queryExecute(" + INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder) + VALUES (:burgerId, 11196, 2) + ", { burgerId: burgerId }, { datasource: "payfrit" }); + } + arrayAppend(actions, { action: dryRun ? "WOULD_LINK" : "LINKED", menuItemID: burgerId, templateID: 11196 }); + } +} + +// Verify the result +if (!dryRun) { + qVerify = queryExecute(" + SELECT mi.ItemID, mi.ItemName, GROUP_CONCAT(t.ItemName ORDER BY tl.SortOrder) as Templates + FROM Items mi + LEFT JOIN ItemTemplateLinks tl ON tl.ItemID = mi.ItemID + LEFT JOIN Items t ON t.ItemID = tl.TemplateItemID + WHERE mi.ItemID IN (:burgerIds) + GROUP BY mi.ItemID, mi.ItemName + ", { burgerIds: { value: arrayToList(burgerIds), list: true } }, { datasource: "payfrit" }); + + result = []; + for (row in qVerify) { + arrayAppend(result, { itemID: row.ItemID, name: row.ItemName, templates: row.Templates }); + } +} else { + result = "Dry run - no changes made"; +} + +writeOutput(serializeJSON({ + "OK": true, + "DryRun": dryRun, + "Actions": actions, + "BurgersAfterFix": result +})); + diff --git a/api/admin/fixShakeFlavors.cfm b/api/admin/fixShakeFlavors.cfm new file mode 100644 index 0000000..ac6cecb --- /dev/null +++ b/api/admin/fixShakeFlavors.cfm @@ -0,0 +1,159 @@ + + + + + + + #serializeJSON({"OK": false, "ERROR": "admin_only"})# + + + + +/** + * Fix Real Ice Cream Shake modifier structure + * + * Current (wrong): + * Real Ice Cream Shake (5808) + * ├── Chocolate (6294) - marked as template + * ├── Strawberry (6292) - marked as template + * └── Vanilla (6293) - marked as template + * + * Correct: + * Real Ice Cream Shake (5808) + * └── Choose Flavor (NEW - modifier group, REQUIRED) + * ├── Chocolate + * ├── Strawberry + * └── Vanilla + * + * Steps: + * 1. Create "Choose Flavor" modifier group under Shake (5808) + * 2. Re-parent the three flavors under the new group + * 3. Remove template flag from flavors + * 4. Mark "Choose Flavor" as the template + * 5. Create ItemTemplateLinks entry for Shake -> Choose Flavor + * 6. Set ItemRequiresChildSelection=1 on the shake item + */ + +response = { "OK": false, "steps": [] }; + +try { + shakeItemID = 5808; + chocolateID = 6294; + strawberryID = 6292; + vanillaID = 6293; + categoryID = 46; // Fries and Shakes + + // Step 1: Create "Choose Flavor" modifier group under Shake + queryExecute(" + INSERT INTO Items ( + ItemCategoryID, + ItemName, + ItemDescription, + ItemParentItemID, + ItemPrice, + ItemIsActive, + ItemIsCheckedByDefault, + ItemRequiresChildSelection, + ItemMaxNumSelectionReq, + ItemIsCollapsible, + ItemSortOrder, + ItemIsModifierTemplate, + ItemAddedOn + ) VALUES ( + :categoryID, + 'Choose Flavor', + '', + :shakeItemID, + 0, + 1, + 0, + 1, + 1, + 1, + 0, + 1, + NOW() + ) + ", { + categoryID: categoryID, + shakeItemID: shakeItemID + }, { datasource: "payfrit" }); + + // Get the new Choose Flavor ID + qNewGroup = queryExecute(" + SELECT ItemID FROM Items + WHERE ItemName = 'Choose Flavor' + AND ItemParentItemID = :shakeItemID + ORDER BY ItemID DESC + LIMIT 1 + ", { shakeItemID: shakeItemID }, { datasource: "payfrit" }); + + chooseFlavorID = qNewGroup.ItemID; + arrayAppend(response.steps, "Created 'Choose Flavor' group with ID: " & chooseFlavorID); + + // Step 2: Re-parent the three flavors under Choose Flavor + queryExecute(" + UPDATE Items + SET ItemParentItemID = :chooseFlavorID, + ItemIsModifierTemplate = 0, + ItemIsCheckedByDefault = 0 + WHERE ItemID IN (:chocolateID, :strawberryID, :vanillaID) + ", { + chooseFlavorID: chooseFlavorID, + chocolateID: chocolateID, + strawberryID: strawberryID, + vanillaID: vanillaID + }, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Re-parented Chocolate, Strawberry, Vanilla under Choose Flavor"); + + // Step 3: Set Vanilla as default (common choice) + queryExecute(" + UPDATE Items SET ItemIsCheckedByDefault = 1 WHERE ItemID = :vanillaID + ", { vanillaID: vanillaID }, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Set Vanilla as default flavor"); + + // Step 4: Remove old template links for the flavors + queryExecute(" + DELETE FROM ItemTemplateLinks + WHERE TemplateItemID IN (:chocolateID, :strawberryID, :vanillaID) + ", { + chocolateID: chocolateID, + strawberryID: strawberryID, + vanillaID: vanillaID + }, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Removed old template links for flavor items"); + + // Step 5: Create ItemTemplateLinks for Shake -> Choose Flavor + queryExecute(" + INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder) + VALUES (:shakeItemID, :chooseFlavorID, 0) + ON DUPLICATE KEY UPDATE SortOrder = 0 + ", { + shakeItemID: shakeItemID, + chooseFlavorID: chooseFlavorID + }, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Created template link: Shake -> Choose Flavor"); + + // Step 6: Set ItemRequiresChildSelection on shake + queryExecute(" + UPDATE Items + SET ItemRequiresChildSelection = 1 + WHERE ItemID = :shakeItemID + ", { shakeItemID: shakeItemID }, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Set ItemRequiresChildSelection=1 on Shake item"); + + response["OK"] = true; + response["chooseFlavorID"] = chooseFlavorID; + +} catch (any e) { + response["ERROR"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/migrateModifierTemplates.cfm b/api/admin/migrateModifierTemplates.cfm new file mode 100644 index 0000000..2a08d36 --- /dev/null +++ b/api/admin/migrateModifierTemplates.cfm @@ -0,0 +1,186 @@ + + + + + + + #serializeJSON({"OK": false, "ERROR": "admin_only"})# + + + + +/** + * Migrate Modifier Templates for In and Out Burger (BusinessID=1) + * + * This script: + * 1. Identifies unique modifier trees (children of parent items) + * 2. Keeps ONE instance of each unique modifier name as the template + * 3. Links all parent items to these templates + * 4. Reports which duplicate items can be deleted + */ + +response = { "OK": false, "steps": [], "templates": [], "links": [], "orphans": [] }; + +try { + businessID = 17; // In and Out Burger + + // Step 1: Get all parent items (burgers, combos, etc.) + qParentItems = queryExecute(" + SELECT i.ItemID, i.ItemName + FROM Items i + INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID + WHERE c.CategoryBusinessID = :businessID + AND i.ItemParentItemID = 0 + AND i.ItemIsActive = 1 + ORDER BY i.ItemName + ", { businessID: businessID }, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Found " & qParentItems.recordCount & " parent items"); + + // Step 2: Get all direct children (level 1 modifiers) of parent items + qModifiers = queryExecute(" + SELECT + m.ItemID, + m.ItemName, + m.ItemParentItemID, + m.ItemPrice, + m.ItemIsCheckedByDefault, + m.ItemSortOrder, + p.ItemName as ParentName + FROM Items m + INNER JOIN Items p ON p.ItemID = m.ItemParentItemID + INNER JOIN Categories c ON c.CategoryID = p.ItemCategoryID + WHERE c.CategoryBusinessID = :businessID + AND m.ItemParentItemID > 0 + AND m.ItemIsActive = 1 + AND p.ItemParentItemID = 0 + ORDER BY m.ItemName, m.ItemID + ", { businessID: businessID }, { datasource: "payfrit" }); + + arrayAppend(response.steps, "Found " & qModifiers.recordCount & " level-1 modifiers"); + + // Step 3: Group modifiers by name to find duplicates + modifiersByName = {}; + for (mod in qModifiers) { + modName = mod.ItemName; + if (!structKeyExists(modifiersByName, modName)) { + modifiersByName[modName] = []; + } + arrayAppend(modifiersByName[modName], { + "ItemID": mod.ItemID, + "ParentItemID": mod.ItemParentItemID, + "ParentName": mod.ParentName, + "Price": mod.ItemPrice, + "IsDefault": mod.ItemIsCheckedByDefault, + "SortOrder": mod.ItemSortOrder + }); + } + + // Step 4: For each unique modifier name, pick ONE as the template + // Keep the first occurrence (usually oldest/original) + templateMap = {}; // modifierName -> templateItemID + orphanItems = []; // Items to delete later + + for (modName in modifiersByName) { + instances = modifiersByName[modName]; + + if (arrayLen(instances) > 1) { + // Multiple instances - first one becomes template + templateItem = instances[1]; + templateItemID = templateItem.ItemID; + + // Mark as template + queryExecute(" + UPDATE Items SET ItemIsModifierTemplate = 1 WHERE ItemID = :itemID + ", { itemID: templateItemID }, { datasource: "payfrit" }); + + templateMap[modName] = templateItemID; + + arrayAppend(response.templates, { + "name": modName, + "templateItemID": templateItemID, + "originalParent": templateItem.ParentName, + "duplicateCount": arrayLen(instances) - 1 + }); + + // Create links for ALL parent items that had this modifier + for (i = 1; i <= arrayLen(instances); i++) { + inst = instances[i]; + parentItemID = inst.ParentItemID; + + // Insert link (ignore duplicates) + try { + queryExecute(" + INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder) + VALUES (:itemID, :templateID, :sortOrder) + ON DUPLICATE KEY UPDATE SortOrder = :sortOrder + ", { + itemID: parentItemID, + templateID: templateItemID, + sortOrder: inst.SortOrder + }, { datasource: "payfrit" }); + + arrayAppend(response.links, { + "parentItemID": parentItemID, + "parentName": inst.ParentName, + "templateItemID": templateItemID, + "templateName": modName + }); + } catch (any linkErr) { + // Link already exists, ignore + } + + // Mark duplicates (not the template) as orphans + if (i > 1) { + arrayAppend(orphanItems, { + "ItemID": inst.ItemID, + "ItemName": modName, + "WasUnder": inst.ParentName + }); + } + } + } else { + // Single instance - still mark as template for consistency + singleItem = instances[1]; + queryExecute(" + UPDATE Items SET ItemIsModifierTemplate = 1 WHERE ItemID = :itemID + ", { itemID: singleItem.ItemID }, { datasource: "payfrit" }); + + // Create link + queryExecute(" + INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder) + VALUES (:itemID, :templateID, :sortOrder) + ON DUPLICATE KEY UPDATE SortOrder = :sortOrder + ", { + itemID: singleItem.ParentItemID, + templateID: singleItem.ItemID, + sortOrder: singleItem.SortOrder + }, { datasource: "payfrit" }); + + arrayAppend(response.templates, { + "name": modName, + "templateItemID": singleItem.ItemID, + "originalParent": singleItem.ParentName, + "duplicateCount": 0 + }); + } + } + + response["orphans"] = orphanItems; + response["orphanCount"] = arrayLen(orphanItems); + response["templateCount"] = arrayLen(response.templates); + response["linkCount"] = arrayLen(response.links); + + arrayAppend(response.steps, "Created " & arrayLen(response.templates) & " templates"); + arrayAppend(response.steps, "Created " & arrayLen(response.links) & " links"); + arrayAppend(response.steps, "Identified " & arrayLen(orphanItems) & " orphan items for deletion"); + + response["OK"] = true; + +} catch (any e) { + response["ERROR"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/setupModifierTemplates.cfm b/api/admin/setupModifierTemplates.cfm new file mode 100644 index 0000000..e8159b2 --- /dev/null +++ b/api/admin/setupModifierTemplates.cfm @@ -0,0 +1,60 @@ + + + + + + + #serializeJSON({"OK": false, "ERROR": "admin_only"})# + + + + +/** + * Setup Modifier Templates system + * 1. Add ItemIsModifierTemplate column to Items + * 2. Create ItemTemplateLinks table + */ + +response = { "OK": false, "steps": [] }; + +try { + // Step 1: Add ItemIsModifierTemplate column if it doesn't exist + try { + queryExecute(" + ALTER TABLE Items ADD COLUMN ItemIsModifierTemplate TINYINT(1) DEFAULT 0 + ", {}, { datasource: "payfrit" }); + arrayAppend(response.steps, "Added ItemIsModifierTemplate column"); + } catch (any e) { + if (findNoCase("Duplicate column", e.message)) { + arrayAppend(response.steps, "ItemIsModifierTemplate column already exists"); + } else { + arrayAppend(response.steps, "Error adding column: " & e.message); + } + } + + // Step 2: Create ItemTemplateLinks table if it doesn't exist + try { + queryExecute(" + CREATE TABLE IF NOT EXISTS ItemTemplateLinks ( + LinkID INT AUTO_INCREMENT PRIMARY KEY, + ItemID INT NOT NULL, + TemplateItemID INT NOT NULL, + SortOrder INT DEFAULT 0, + UNIQUE KEY unique_link (ItemID, TemplateItemID), + INDEX idx_item (ItemID), + INDEX idx_template (TemplateItemID) + ) + ", {}, { datasource: "payfrit" }); + arrayAppend(response.steps, "Created ItemTemplateLinks table"); + } catch (any e) { + arrayAppend(response.steps, "ItemTemplateLinks: " & e.message); + } + + response["OK"] = true; + +} catch (any e) { + response["ERROR"] = e.message; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/setupStations.cfm b/api/admin/setupStations.cfm new file mode 100644 index 0000000..59556c1 --- /dev/null +++ b/api/admin/setupStations.cfm @@ -0,0 +1,81 @@ + + + + + + + #serializeJSON(arguments.payload)# + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/admin/switchBeacons.cfm b/api/admin/switchBeacons.cfm new file mode 100644 index 0000000..40808b3 --- /dev/null +++ b/api/admin/switchBeacons.cfm @@ -0,0 +1,38 @@ + + + + + +// Switch all beacons from one business to another +fromBiz = 17; // In-N-Out +toBiz = 27; // Big Dean's + +queryExecute(" + UPDATE lt_Beacon_Businesses_ServicePoints + SET BusinessID = :toBiz + WHERE BusinessID = :fromBiz +", { toBiz: toBiz, fromBiz: fromBiz }, { datasource: "payfrit" }); + +// Get current state +q = queryExecute(" + SELECT lt.*, b.BusinessName + FROM lt_Beacon_Businesses_ServicePoints lt + JOIN Businesses b ON b.BusinessID = lt.BusinessID +", {}, { datasource: "payfrit" }); + +rows = []; +for (row in q) { + arrayAppend(rows, { + "BeaconID": row.BeaconID, + "BusinessID": row.BusinessID, + "BusinessName": row.BusinessName, + "ServicePointID": row.ServicePointID + }); +} + +writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Switched beacons from BusinessID #fromBiz# to #toBiz#", + "MAPPINGS": rows +})); + diff --git a/api/auth/login.cfm b/api/auth/login.cfm index 7f4ed93..63a0fb2 100644 --- a/api/auth/login.cfm +++ b/api/auth/login.cfm @@ -1,5 +1,7 @@ + + /* diff --git a/api/menu/getForBuilder.cfm b/api/menu/getForBuilder.cfm index 8f9bcc5..adfd7c7 100644 --- a/api/menu/getForBuilder.cfm +++ b/api/menu/getForBuilder.cfm @@ -1,108 +1,318 @@ + + + + + -// Get menu data formatted for the builder UI -// Input: BusinessID -// Output: { OK: true, MENU: { categories: [...] } } - -param name="form.BusinessID" default="0"; -param name="url.BusinessID" default="#form.BusinessID#"; - -businessID = val(url.BusinessID); +/** + * Get Menu for Builder + * Returns categories and items in structured format for the menu builder UI + * + * POST: { BusinessID: int } + * + * Unified schema: + * - Categories = Items at ParentID=0 that have menu items as children + * - Templates = Items at ParentID=0 that appear in ItemTemplateLinks + * - Menu items have ItemParentItemID pointing to their category + * - All items have ItemBusinessID for filtering + */ response = { "OK": false }; try { - if (businessID == 0) { - // Try to get from request body - requestBody = toString(getHttpRequestData().content); - if (len(requestBody)) { - jsonData = deserializeJSON(requestBody); - businessID = val(jsonData.BusinessID ?: 0); - } + // Get request body + requestBody = toString(getHttpRequestData().content); + requestData = {}; + if (len(requestBody)) { + requestData = deserializeJSON(requestBody); + } + + businessID = 0; + if (structKeyExists(requestData, "BusinessID")) { + businessID = val(requestData.BusinessID); } if (businessID == 0) { - throw("BusinessID is required"); + response["ERROR"] = "missing_business_id"; + response["MESSAGE"] = "BusinessID is required"; + writeOutput(serializeJSON(response)); + abort; } - // Get categories - categories = queryExecute(" - SELECT CategoryID, CategoryName, CategoryDescription, CategorySortOrder - FROM Categories - WHERE CategoryBusinessID = :businessID - ORDER BY CategorySortOrder, CategoryName - ", { businessID: businessID }); + // Check if new schema is active (ItemBusinessID column exists and has data) + newSchemaActive = false; + try { + qCheck = queryExecute(" + SELECT COUNT(*) as cnt FROM Items + WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0 + ", { businessID: businessID }, { datasource: "payfrit" }); + newSchemaActive = (qCheck.cnt > 0); + } catch (any e) { + newSchemaActive = false; + } - menuCategories = []; + if (newSchemaActive) { + // NEW SCHEMA: Categories are Items at ParentID=0 with children (not in ItemTemplateLinks) + qCategories = queryExecute(" + 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" }); - for (cat in categories) { - // Get items for this category (without Tasks join which may not exist) - items = queryExecute(" - SELECT i.ItemID, i.ItemName, i.ItemDescription, i.ItemPrice, - i.ItemIsActive, i.ItemSortOrder + // Get all menu items (children of category Items, not templates) + qItems = queryExecute(" + SELECT + i.ItemID, + i.ItemParentItemID as CategoryItemID, + i.ItemName, + i.ItemDescription, + i.ItemPrice, + i.ItemSortOrder, + i.ItemIsActive FROM Items i - WHERE i.ItemCategoryID = :categoryID + 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" }); + + } else { + // OLD SCHEMA: Use Categories table + qCategories = queryExecute(" + SELECT + CategoryID, + CategoryName, + 0 as ItemSortOrder + FROM Categories + WHERE CategoryBusinessID = :businessID + ORDER BY CategoryName + ", { businessID: businessID }, { datasource: "payfrit" }); + + qItems = queryExecute(" + SELECT + i.ItemID, + i.ItemCategoryID as CategoryItemID, + i.ItemName, + i.ItemDescription, + i.ItemPrice, + i.ItemSortOrder, + i.ItemIsActive + FROM Items i + INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID + WHERE c.CategoryBusinessID = :businessID + AND i.ItemIsActive = 1 AND i.ItemParentItemID = 0 ORDER BY i.ItemSortOrder, i.ItemName - ", { categoryID: cat.CategoryID }); + ", { businessID: businessID }, { datasource: "payfrit" }); + } - categoryItems = []; + // Get template links (which templates are linked to which menu items) + qTemplateLinks = queryExecute(" + SELECT + tl.ItemID as ParentItemID, + tl.TemplateItemID, + tl.SortOrder, + t.ItemName as TemplateName, + t.ItemPrice as TemplatePrice, + t.ItemIsCheckedByDefault as TemplateIsDefault + FROM ItemTemplateLinks tl + INNER JOIN Items t ON t.ItemID = tl.TemplateItemID + ORDER BY tl.ItemID, tl.SortOrder + ", {}, { datasource: "payfrit" }); - for (item in items) { - // Get modifiers for this item - modifiers = queryExecute(" - SELECT ItemID, ItemName, ItemPrice, ItemIsCheckedByDefault, ItemSortOrder - FROM Items - WHERE ItemParentItemID = :itemID - ORDER BY ItemSortOrder, ItemName - ", { itemID: item.ItemID }); + // Get all templates for this business (items that appear in ItemTemplateLinks) + if (newSchemaActive) { + qTemplates = queryExecute(" + 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 + INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID + INNER JOIN Items i ON i.ItemID = tl.ItemID + WHERE i.ItemBusinessID = :businessID + AND t.ItemIsActive = 1 + ORDER BY t.ItemSortOrder, t.ItemName + ", { businessID: businessID }, { datasource: "payfrit" }); + } else { + qTemplates = queryExecute(" + 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 + INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID + INNER JOIN Items i ON i.ItemID = tl.ItemID + INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID + WHERE c.CategoryBusinessID = :businessID + AND t.ItemIsActive = 1 + ORDER BY t.ItemSortOrder, t.ItemName + ", { businessID: businessID }, { datasource: "payfrit" }); + } - itemModifiers = []; - for (mod in modifiers) { - arrayAppend(itemModifiers, { - "id": mod.ItemID, - "name": mod.ItemName, - "price": mod.ItemPrice, - "isDefault": mod.ItemIsCheckedByDefault == 1, - "sortOrder": mod.ItemSortOrder - }); - } + // Get all children of templates (options within modifier groups) + // Filter by business and use DISTINCT to avoid duplicates from multiple template links + qTemplateChildren = queryExecute(" + SELECT DISTINCT + c.ItemID, + c.ItemParentItemID as ParentItemID, + c.ItemName, + c.ItemPrice, + c.ItemIsCheckedByDefault as IsDefault, + c.ItemSortOrder + FROM Items c + WHERE c.ItemParentItemID IN ( + SELECT DISTINCT t.ItemID + FROM Items t + INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID + INNER JOIN Items i ON i.ItemID = tl.ItemID + WHERE i.ItemBusinessID = :businessID + ) + AND c.ItemIsActive = 1 + ORDER BY c.ItemSortOrder, c.ItemName + ", { businessID: businessID }, { datasource: "payfrit" }); - arrayAppend(categoryItems, { - "id": item.ItemID, - "name": item.ItemName, - "description": item.ItemDescription ?: "", - "price": item.ItemPrice, - "imageUrl": "", - "photoTaskId": "", - "modifiers": itemModifiers, - "sortOrder": item.ItemSortOrder - }); + // Build lookup of children by template ID + childrenByTemplate = {}; + for (child in qTemplateChildren) { + parentID = child.ParentItemID; + if (!structKeyExists(childrenByTemplate, parentID)) { + childrenByTemplate[parentID] = []; } - - arrayAppend(menuCategories, { - "id": cat.CategoryID, - "name": cat.CategoryName, - "description": cat.CategoryDescription ?: "", - "sortOrder": cat.CategorySortOrder, - "items": categoryItems + arrayAppend(childrenByTemplate[parentID], { + "id": "opt_" & child.ItemID, + "dbId": child.ItemID, + "name": child.ItemName, + "price": child.ItemPrice, + "isDefault": child.IsDefault == 1 ? true : false, + "sortOrder": child.ItemSortOrder, + "options": [] }); } - response = { - "OK": true, - "MENU": { - "categories": menuCategories + // Build template lookup with their children + templatesById = {}; + for (tmpl in qTemplates) { + templateID = tmpl.ItemID; + children = structKeyExists(childrenByTemplate, templateID) ? childrenByTemplate[templateID] : []; + templatesById[templateID] = { + "id": "mod_" & tmpl.ItemID, + "dbId": tmpl.ItemID, + "name": tmpl.ItemName, + "price": tmpl.ItemPrice, + "isDefault": tmpl.IsDefault == 1 ? true : false, + "sortOrder": tmpl.ItemSortOrder, + "isTemplate": true, + "requiresSelection": isNull(tmpl.RequiresSelection) ? false : (tmpl.RequiresSelection == 1), + "maxSelections": isNull(tmpl.MaxSelections) ? 0 : tmpl.MaxSelections, + "options": children + }; + } + + // Build modifier lookup by parent ItemID using template links + modifiersByItem = {}; + for (link in qTemplateLinks) { + parentID = link.ParentItemID; + templateID = link.TemplateItemID; + + if (!structKeyExists(modifiersByItem, parentID)) { + modifiersByItem[parentID] = []; } - }; + + if (structKeyExists(templatesById, templateID)) { + tmpl = duplicate(templatesById[templateID]); + tmpl["sortOrder"] = link.SortOrder; + arrayAppend(modifiersByItem[parentID], tmpl); + } + } + + // Build items lookup by CategoryID + itemsByCategory = {}; + for (item in qItems) { + catID = item.CategoryItemID; + if (!structKeyExists(itemsByCategory, catID)) { + itemsByCategory[catID] = []; + } + + itemID = item.ItemID; + itemModifiers = structKeyExists(modifiersByItem, itemID) ? modifiersByItem[itemID] : []; + + arrayAppend(itemsByCategory[catID], { + "id": "item_" & item.ItemID, + "dbId": item.ItemID, + "name": item.ItemName, + "description": isNull(item.ItemDescription) ? "" : item.ItemDescription, + "price": item.ItemPrice, + "imageUrl": javaCast("null", ""), + "photoTaskId": javaCast("null", ""), + "modifiers": itemModifiers, + "sortOrder": item.ItemSortOrder + }); + } + + // Build categories array + categories = []; + catIndex = 0; + for (cat in qCategories) { + catID = cat.CategoryID; + catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : []; + + arrayAppend(categories, { + "id": "cat_" & cat.CategoryID, + "dbId": cat.CategoryID, + "name": cat.CategoryName, + "description": "", + "sortOrder": catIndex, + "items": catItems + }); + catIndex++; + } + + // Build template library array for the UI + templateLibrary = []; + for (templateID in templatesById) { + arrayAppend(templateLibrary, templatesById[templateID]); + } + + response["OK"] = true; + response["MENU"] = { "categories": categories }; + response["TEMPLATES"] = templateLibrary; + response["CATEGORY_COUNT"] = arrayLen(categories); + response["TEMPLATE_COUNT"] = arrayLen(templateLibrary); + response["SCHEMA"] = newSchemaActive ? "unified" : "legacy"; + + totalItems = 0; + for (cat in categories) { + totalItems += arrayLen(cat.items); + } + response["ITEM_COUNT"] = totalItems; } catch (any e) { - response = { - "OK": false, - "ERROR": e.message, - "DETAIL": e.detail ?: "" - }; + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; } -cfheader(name="Content-Type", value="application/json"); writeOutput(serializeJSON(response)); diff --git a/api/menu/items.cfm b/api/menu/items.cfm index 87248ee..82d70d3 100644 --- a/api/menu/items.cfm +++ b/api/menu/items.cfm @@ -37,31 +37,106 @@ - + + + + 0", + [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], + { datasource = "payfrit" } + )> + + + + + + + + + + 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" } + )> + + + + @@ -78,15 +153,150 @@ "ItemRequiresChildSelection": q.ItemRequiresChildSelection, "ItemMaxNumSelectionReq": q.ItemMaxNumSelectionReq, "ItemIsCollapsible": q.ItemIsCollapsible, - "ItemSortOrder": q.ItemSortOrder + "ItemSortOrder": q.ItemSortOrder, + "ItemStationID": len(trim(q.ItemStationID)) ? q.ItemStationID : "", + "ItemStationName": len(trim(q.StationName)) ? q.StationName : "", + "ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : "" })> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/menu/saveFromBuilder.cfm b/api/menu/saveFromBuilder.cfm index d45aca4..1a0bc3e 100644 --- a/api/menu/saveFromBuilder.cfm +++ b/api/menu/saveFromBuilder.cfm @@ -2,6 +2,8 @@ // Save menu data from the builder UI // Input: BusinessID, Menu (JSON structure) // Output: { OK: true } +// +// Supports both old schema (Categories table) and new unified schema (Categories as Items) response = { "OK": false }; @@ -23,90 +25,206 @@ try { throw("Menu categories are required"); } + // Check if new schema is active (ItemBusinessID column exists and has data) + newSchemaActive = false; + try { + qCheck = queryExecute(" + SELECT COUNT(*) as cnt FROM Items + WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0 + ", { businessID: businessID }); + newSchemaActive = (qCheck.cnt > 0); + } catch (any e) { + newSchemaActive = false; + } + // Process each category for (cat in menu.categories) { categoryID = 0; + categoryDbId = structKeyExists(cat, "dbId") ? val(cat.dbId) : 0; - // Check if it's an existing category (numeric ID) or new (temp_ prefix) - if (isNumeric(cat.id)) { - categoryID = val(cat.id); - // Update existing category - queryExecute(" - UPDATE Categories - SET CategoryName = :name, - CategoryDescription = :description, - CategorySortOrder = :sortOrder - WHERE CategoryID = :categoryID - ", { - categoryID: categoryID, - name: cat.name, - description: cat.description ?: "", - sortOrder: val(cat.sortOrder ?: 0) - }); + if (newSchemaActive) { + // NEW SCHEMA: Categories are Items with ParentID=0 and Template=0 + if (categoryDbId > 0) { + categoryID = categoryDbId; + // Update existing category Item + queryExecute(" + UPDATE Items + SET ItemName = :name, + ItemSortOrder = :sortOrder + WHERE ItemID = :categoryID + AND ItemBusinessID = :businessID + ", { + categoryID: categoryID, + businessID: businessID, + name: cat.name, + sortOrder: val(cat.sortOrder ?: 0) + }); + } else { + // Insert new category as Item + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, ItemName, ItemDescription, + ItemParentItemID, ItemPrice, ItemIsActive, + ItemSortOrder, ItemIsModifierTemplate, ItemAddedOn + ) VALUES ( + :businessID, :name, '', + 0, 0, 1, + :sortOrder, 0, NOW() + ) + ", { + businessID: businessID, + name: cat.name, + sortOrder: val(cat.sortOrder ?: 0) + }); + + result = queryExecute("SELECT LAST_INSERT_ID() as newID"); + categoryID = result.newID; + } } else { - // Insert new category - queryExecute(" - INSERT INTO Categories (CategoryBusinessID, CategoryName, CategoryDescription, CategorySortOrder) - VALUES (:businessID, :name, :description, :sortOrder) - ", { - businessID: businessID, - name: cat.name, - description: cat.description ?: "", - sortOrder: val(cat.sortOrder ?: 0) - }); + // OLD SCHEMA: Use Categories table + if (categoryDbId > 0) { + categoryID = categoryDbId; + queryExecute(" + UPDATE Categories + SET CategoryName = :name, + CategoryDescription = :description, + CategorySortOrder = :sortOrder + WHERE CategoryID = :categoryID + ", { + categoryID: categoryID, + name: cat.name, + description: cat.description ?: "", + sortOrder: val(cat.sortOrder ?: 0) + }); + } else { + queryExecute(" + INSERT INTO Categories (CategoryBusinessID, CategoryName, CategoryDescription, CategorySortOrder) + VALUES (:businessID, :name, :description, :sortOrder) + ", { + businessID: businessID, + name: cat.name, + description: cat.description ?: "", + sortOrder: val(cat.sortOrder ?: 0) + }); - // Get the new category ID - result = queryExecute("SELECT LAST_INSERT_ID() as newID"); - categoryID = result.newID; + result = queryExecute("SELECT LAST_INSERT_ID() as newID"); + categoryID = result.newID; + } } // Process items in this category if (structKeyExists(cat, "items") && isArray(cat.items)) { for (item in cat.items) { itemID = 0; + itemDbId = structKeyExists(item, "dbId") ? val(item.dbId) : 0; - if (isNumeric(item.id)) { - itemID = val(item.id); - // Update existing item (without ImageURL which may not exist) - queryExecute(" - UPDATE Items - SET ItemName = :name, - ItemDescription = :description, - ItemPrice = :price, - ItemCategoryID = :categoryID, - ItemSortOrder = :sortOrder - WHERE ItemID = :itemID - ", { - itemID: itemID, - name: item.name, - description: item.description ?: "", - price: val(item.price ?: 0), - categoryID: categoryID, - sortOrder: val(item.sortOrder ?: 0) - }); + if (itemDbId > 0) { + itemID = itemDbId; + + if (newSchemaActive) { + // Update existing item - set parent to category Item + queryExecute(" + UPDATE Items + SET ItemName = :name, + ItemDescription = :description, + ItemPrice = :price, + ItemParentItemID = :categoryID, + ItemSortOrder = :sortOrder + WHERE ItemID = :itemID + ", { + itemID: itemID, + name: item.name, + description: item.description ?: "", + price: val(item.price ?: 0), + categoryID: categoryID, + sortOrder: val(item.sortOrder ?: 0) + }); + } else { + // Update existing item - old schema with CategoryID + queryExecute(" + UPDATE Items + SET ItemName = :name, + ItemDescription = :description, + ItemPrice = :price, + ItemCategoryID = :categoryID, + ItemSortOrder = :sortOrder + WHERE ItemID = :itemID + ", { + itemID: itemID, + name: item.name, + description: item.description ?: "", + price: val(item.price ?: 0), + categoryID: categoryID, + sortOrder: val(item.sortOrder ?: 0) + }); + } } else { - // Insert new item (without ImageURL which may not exist) - queryExecute(" - INSERT INTO Items (ItemBusinessID, ItemCategoryID, ItemName, ItemDescription, ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID) - VALUES (:businessID, :categoryID, :name, :description, :price, :sortOrder, 1, 0) - ", { - businessID: businessID, - categoryID: categoryID, - name: item.name, - description: item.description ?: "", - price: val(item.price ?: 0), - sortOrder: val(item.sortOrder ?: 0) - }); + // Insert new item + if (newSchemaActive) { + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, ItemParentItemID, ItemName, ItemDescription, + ItemPrice, ItemSortOrder, ItemIsActive, ItemAddedOn + ) VALUES ( + :businessID, :categoryID, :name, :description, + :price, :sortOrder, 1, NOW() + ) + ", { + businessID: businessID, + categoryID: categoryID, + name: item.name, + description: item.description ?: "", + price: val(item.price ?: 0), + sortOrder: val(item.sortOrder ?: 0) + }); + } else { + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, ItemCategoryID, ItemName, ItemDescription, + ItemPrice, ItemSortOrder, ItemIsActive, ItemParentItemID + ) VALUES ( + :businessID, :categoryID, :name, :description, + :price, :sortOrder, 1, 0 + ) + ", { + businessID: businessID, + categoryID: categoryID, + name: item.name, + description: item.description ?: "", + price: val(item.price ?: 0), + sortOrder: val(item.sortOrder ?: 0) + }); + } result = queryExecute("SELECT LAST_INSERT_ID() as newID"); itemID = result.newID; } - // Process modifiers for this item + // Handle template links for modifiers if (structKeyExists(item, "modifiers") && isArray(item.modifiers)) { + // Clear existing template links for this item + queryExecute(" + DELETE FROM ItemTemplateLinks WHERE ItemID = :itemID + ", { itemID: itemID }); + + modSortOrder = 0; for (mod in item.modifiers) { - if (isNumeric(mod.id)) { - // Update existing modifier + modDbId = structKeyExists(mod, "dbId") ? val(mod.dbId) : 0; + + // Check if this is a template reference + if (structKeyExists(mod, "isTemplate") && mod.isTemplate && modDbId > 0) { + // Create template link + queryExecute(" + INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder) + VALUES (:itemID, :templateID, :sortOrder) + ON DUPLICATE KEY UPDATE SortOrder = :sortOrder + ", { + itemID: itemID, + templateID: modDbId, + sortOrder: modSortOrder + }); + } else if (modDbId > 0) { + // Update existing direct modifier queryExecute(" UPDATE Items SET ItemName = :name, @@ -115,34 +233,39 @@ try { ItemSortOrder = :sortOrder WHERE ItemID = :modID ", { - modID: val(mod.id), + modID: modDbId, name: mod.name, price: val(mod.price ?: 0), isDefault: (mod.isDefault ?: false) ? 1 : 0, - sortOrder: val(mod.sortOrder ?: 0) + sortOrder: modSortOrder }); } else { - // Insert new modifier + // Insert new direct modifier (non-template) queryExecute(" - INSERT INTO Items (ItemBusinessID, ItemCategoryID, ItemParentItemID, ItemName, ItemPrice, ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive) - VALUES (:businessID, :categoryID, :parentID, :name, :price, :isDefault, :sortOrder, 1) + INSERT INTO Items ( + ItemBusinessID, ItemParentItemID, ItemName, ItemPrice, + ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn + ) VALUES ( + :businessID, :parentID, :name, :price, + :isDefault, :sortOrder, 1, NOW() + ) ", { businessID: businessID, - categoryID: categoryID, parentID: itemID, name: mod.name, price: val(mod.price ?: 0), isDefault: (mod.isDefault ?: false) ? 1 : 0, - sortOrder: val(mod.sortOrder ?: 0) + sortOrder: modSortOrder }); } + modSortOrder++; } } } } } - response = { "OK": true }; + response = { "OK": true, "SCHEMA": newSchemaActive ? "unified" : "legacy" }; } catch (any e) { response = { diff --git a/api/menu/updateStations.cfm b/api/menu/updateStations.cfm new file mode 100644 index 0000000..b7fd30e --- /dev/null +++ b/api/menu/updateStations.cfm @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + #serializeJSON(arguments.payload)# + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/portal/myBusinesses.cfm b/api/portal/myBusinesses.cfm new file mode 100644 index 0000000..a0dbfc0 --- /dev/null +++ b/api/portal/myBusinesses.cfm @@ -0,0 +1,75 @@ + + + + + + + +/** + * Get Businesses for User + * Returns list of businesses the authenticated user has access to + * + * POST: { UserID: int } + * Headers: X-User-Token (optional - will use session if not provided) + */ + +response = { "OK": false }; + +try { + // Get UserID from request or session + userID = 0; + + // Check token auth first + if (structKeyExists(request, "UserID") && isNumeric(request.UserID)) { + userID = int(request.UserID); + } + + // Also check request body + if (userID == 0) { + requestBody = toString(getHttpRequestData().content); + if (len(requestBody)) { + requestData = deserializeJSON(requestBody); + if (structKeyExists(requestData, "UserID")) { + userID = val(requestData.UserID); + } + } + } + + if (userID == 0) { + response["ERROR"] = "not_logged_in"; + response["MESSAGE"] = "User not authenticated"; + writeOutput(serializeJSON(response)); + abort; + } + + // Get businesses for this user + // Users are linked to businesses via BusinessUserID field (owner) + + q = queryExecute(" + SELECT + b.BusinessID, + b.BusinessName + FROM Businesses b + WHERE b.BusinessUserID = :userID + ORDER BY b.BusinessName + ", { userID: userID }, { datasource: "payfrit" }); + + businesses = []; + for (row in q) { + arrayAppend(businesses, { + "BusinessID": row.BusinessID, + "BusinessName": row.BusinessName + }); + } + + response["OK"] = true; + response["BUSINESSES"] = businesses; + response["COUNT"] = arrayLen(businesses); + +} catch (any e) { + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/setup/analyzeMenu.cfm b/api/setup/analyzeMenu.cfm new file mode 100644 index 0000000..307dfeb --- /dev/null +++ b/api/setup/analyzeMenu.cfm @@ -0,0 +1,415 @@ + + + + + +/** + * Analyze Menu for Modifier Patterns + * + * Parses item descriptions to detect modifier groups and suggests + * which templates should be created and applied. + * + * Detection patterns: + * - Size variants: "Small available", "1/2 order available", "Small/Large" + * - Protein choices: "With Chicken / With Steak or Shrimp" + * - Required choices: "Boneless or bone in" + * - Category-wide add-ons: "Add to any salad: ..." + * - Price modifiers: "Add cheese +$0.50" + * + * POST JSON: + * { + * "categories": [ + * { + * "name": "Burgers", + * "categoryNote": "All burgers served with lettuce, tomato...", + * "items": [ + * { "name": "Burger", "description": "Served with fries" } + * ] + * } + * ] + * } + * + * Returns analyzed menu with detected modifiers and suggestions + */ + +response = { "OK": false }; + +// Pattern definitions for modifier detection +patterns = { + // Size patterns + "size_small_available": { + "regex": "(?i)\bsmall\s+(available|option)\b", + "templateName": "Size", + "options": [ + { "name": "Regular", "price": 0, "isDefault": true }, + { "name": "Small", "price": 0, "isDefault": false } + ], + "required": true, + "maxSelections": 1 + }, + "size_half_available": { + "regex": "(?i)\b(1/2|half)\s+(order\s+)?(available|option)\b", + "templateName": "Size", + "options": [ + { "name": "Full", "price": 0, "isDefault": true }, + { "name": "Half", "price": 0, "isDefault": false } + ], + "required": true, + "maxSelections": 1 + }, + // Choice patterns (X or Y) + "choice_or": { + "regex": "(?i)\b(\w+)\s+or\s+(\w+)\b(?!\s+shrimp)", + "templateName": "auto", // Will be generated from matched words + "required": true, + "maxSelections": 1 + }, + // Protein add-ons + "protein_chicken_steak_shrimp": { + "regex": "(?i)with\s+chicken\s*/?\s*(?:with\s+)?(?:steak\s+or\s+shrimp|steak\/shrimp)", + "templateName": "Add Protein", + "options": [ + { "name": "Plain", "price": 0, "isDefault": true }, + { "name": "With Chicken", "price": 0, "isDefault": false }, + { "name": "With Steak or Shrimp", "price": 0, "isDefault": false } + ], + "required": false, + "maxSelections": 1 + }, + // Add-on with price + "addon_with_price": { + "regex": "(?i)add\s+(\w+(?:\s+\w+)?)\s*[\+\$]?\s*\.?(\d+(?:\.\d{2})?)", + "templateName": "Add-ons", + "required": false, + "maxSelections": 0 + }, + // Category note patterns + "salad_addons": { + "regex": "(?i)add\s+(?:the\s+following\s+)?to\s+any\s+salad[:\s]+(.+)", + "templateName": "Add Protein", + "appliesTo": "category", + "required": false, + "maxSelections": 1 + } +}; + +try { + requestBody = toString(getHttpRequestData().content); + if (!len(requestBody)) { + throw(message="No request body provided"); + } + + data = deserializeJSON(requestBody); + categories = structKeyExists(data, "categories") ? data.categories : []; + + // Track detected templates and their usage + detectedTemplates = {}; + itemModifiers = {}; // Maps item name to array of template IDs + + // Analyze each category + analyzedCategories = []; + + for (cat in categories) { + catName = cat.name; + catNote = structKeyExists(cat, "categoryNote") ? cat.categoryNote : ""; + items = structKeyExists(cat, "items") ? cat.items : []; + + // Check for category-wide patterns in the note + categoryTemplates = []; + + // Check for "Add to any salad" pattern + if (reFindNoCase("add\s+(?:the\s+following\s+)?to\s+any\s+(salad|item)", catNote)) { + // Extract the add-on items + addOnMatch = reMatchNoCase("add[^:]+:\s*(.+)", catNote); + if (arrayLen(addOnMatch)) { + addOnText = addOnMatch[1]; + // Split by comma + addOnItems = listToArray(addOnText, ","); + templateID = "cat_" & lCase(reReplace(catName, "\W+", "_", "all")) & "_addons"; + + if (!structKeyExists(detectedTemplates, templateID)) { + options = [{ "name": "None", "price": 0, "isDefault": true }]; + for (addon in addOnItems) { + addon = trim(addon); + if (len(addon)) { + arrayAppend(options, { "name": addon, "price": 0, "isDefault": false }); + } + } + detectedTemplates[templateID] = { + "id": templateID, + "name": "Add Protein", + "options": options, + "required": false, + "maxSelections": 1, + "detectedFrom": "Category note: " & catName, + "appliesTo": [] + }; + } + arrayAppend(categoryTemplates, templateID); + } + } + + // Analyze each item + analyzedItems = []; + for (item in items) { + itemName = item.name; + itemDesc = structKeyExists(item, "description") ? item.description : ""; + itemMods = []; + + // Apply category-wide templates + for (catTmpl in categoryTemplates) { + arrayAppend(itemMods, catTmpl); + arrayAppend(detectedTemplates[catTmpl].appliesTo, itemName); + } + + // Check for size patterns + if (reFindNoCase("\bsmall\s*(available|option)?\b", itemDesc) || + reFindNoCase("\bsmall\b", itemName)) { + templateID = "size_regular_small"; + if (!structKeyExists(detectedTemplates, templateID)) { + detectedTemplates[templateID] = { + "id": templateID, + "name": "Size", + "options": [ + { "name": "Regular", "price": 0, "isDefault": true }, + { "name": "Small", "price": 0, "isDefault": false } + ], + "required": true, + "maxSelections": 1, + "detectedFrom": "Pattern: 'Small available'", + "appliesTo": [] + }; + } + arrayAppend(itemMods, templateID); + arrayAppend(detectedTemplates[templateID].appliesTo, itemName); + } + + // Check for half order pattern + if (reFindNoCase("\b(1/2|half)\s*(order)?\s*(available)?\b", itemDesc)) { + templateID = "size_full_half"; + if (!structKeyExists(detectedTemplates, templateID)) { + detectedTemplates[templateID] = { + "id": templateID, + "name": "Size", + "options": [ + { "name": "Full Order", "price": 0, "isDefault": true }, + { "name": "Half Order", "price": 0, "isDefault": false } + ], + "required": true, + "maxSelections": 1, + "detectedFrom": "Pattern: '1/2 order available'", + "appliesTo": [] + }; + } + arrayAppend(itemMods, templateID); + arrayAppend(detectedTemplates[templateID].appliesTo, itemName); + } + + // Check for "boneless or bone in" pattern + if (reFindNoCase("\bboneless\s+(or|/)\s*bone\s*-?\s*in\b", itemDesc)) { + templateID = "wing_style"; + if (!structKeyExists(detectedTemplates, templateID)) { + detectedTemplates[templateID] = { + "id": templateID, + "name": "Style", + "options": [ + { "name": "Bone-In", "price": 0, "isDefault": true }, + { "name": "Boneless", "price": 0, "isDefault": false } + ], + "required": true, + "maxSelections": 1, + "detectedFrom": "Pattern: 'Boneless or bone in'", + "appliesTo": [] + }; + } + arrayAppend(itemMods, templateID); + arrayAppend(detectedTemplates[templateID].appliesTo, itemName); + } + + // Check for protein add-on pattern (With Chicken / With Steak or Shrimp) + if (reFindNoCase("with\s+chicken.*(?:steak|shrimp)", itemDesc)) { + templateID = "protein_addon"; + if (!structKeyExists(detectedTemplates, templateID)) { + detectedTemplates[templateID] = { + "id": templateID, + "name": "Add Protein", + "options": [ + { "name": "Plain", "price": 0, "isDefault": true }, + { "name": "With Chicken", "price": 0, "isDefault": false }, + { "name": "With Steak or Shrimp", "price": 0, "isDefault": false } + ], + "required": false, + "maxSelections": 1, + "detectedFrom": "Pattern: 'With Chicken / With Steak or Shrimp'", + "appliesTo": [] + }; + } + arrayAppend(itemMods, templateID); + arrayAppend(detectedTemplates[templateID].appliesTo, itemName); + } + + // Check for "add X +$Y" pattern + priceMatch = reMatchNoCase("add\s+(\w+(?:\s+(?:and\s+)?\w+)?)\s*[\.\+]?\s*(\d+(?:\.\d{2})?)", itemDesc); + if (arrayLen(priceMatch)) { + for (match in priceMatch) { + // Parse the match to get item name and price + parts = reMatchNoCase("add\s+(.+?)\s*[\.\+]?\s*(\d+(?:\.\d{2})?)\s*$", match); + if (arrayLen(parts)) { + templateID = "addon_" & lCase(reReplace(itemName, "\W+", "_", "all")); + if (!structKeyExists(detectedTemplates, templateID)) { + detectedTemplates[templateID] = { + "id": templateID, + "name": "Add-ons", + "options": [], + "required": false, + "maxSelections": 0, + "detectedFrom": "Pattern: 'Add X +$Y' in " & itemName, + "appliesTo": [], + "needsPricing": true + }; + } + arrayAppend(itemMods, templateID); + arrayAppend(detectedTemplates[templateID].appliesTo, itemName); + } + } + } + + // Check for "with guacamole" type optional add-ons + if (reFindNoCase("\bwith\s+guacamole\b", itemDesc)) { + templateID = "addon_guacamole"; + if (!structKeyExists(detectedTemplates, templateID)) { + detectedTemplates[templateID] = { + "id": templateID, + "name": "Add Guacamole", + "options": [ + { "name": "No Guacamole", "price": 0, "isDefault": true }, + { "name": "Add Guacamole", "price": 0, "isDefault": false } + ], + "required": false, + "maxSelections": 1, + "detectedFrom": "Pattern: 'With guacamole'", + "appliesTo": [], + "needsPricing": true + }; + } + arrayAppend(itemMods, templateID); + arrayAppend(detectedTemplates[templateID].appliesTo, itemName); + } + + // Store item with detected modifiers + arrayAppend(analyzedItems, { + "name": itemName, + "description": itemDesc, + "price": structKeyExists(item, "price") ? item.price : 0, + "detectedModifiers": itemMods, + "originalDescription": itemDesc + }); + } + + arrayAppend(analyzedCategories, { + "name": catName, + "categoryNote": catNote, + "items": analyzedItems, + "categoryWideTemplates": categoryTemplates + }); + } + + // Build template array sorted by usage count + templateArray = []; + for (key in detectedTemplates) { + tmpl = detectedTemplates[key]; + tmpl["usageCount"] = arrayLen(tmpl.appliesTo); + arrayAppend(templateArray, tmpl); + } + + // Sort by usage count descending + arraySort(templateArray, function(a, b) { + return b.usageCount - a.usageCount; + }); + + // Generate questions for owner confirmation + questions = []; + + // Question about detected templates + if (arrayLen(templateArray)) { + arrayAppend(questions, { + "type": "confirm_templates", + "question": "I detected these modifier groups. Are they correct?", + "templates": templateArray + }); + } + + // Question about items needing pricing + itemsNeedingPrices = []; + for (cat in analyzedCategories) { + for (item in cat.items) { + if (item.price == 0) { + arrayAppend(itemsNeedingPrices, { + "category": cat.name, + "item": item.name + }); + } + } + } + + if (arrayLen(itemsNeedingPrices)) { + arrayAppend(questions, { + "type": "pricing_needed", + "question": "These items need prices:", + "items": itemsNeedingPrices + }); + } + + // Question about template options needing pricing + templatesNeedingPrices = []; + for (tmpl in templateArray) { + if (structKeyExists(tmpl, "needsPricing") && tmpl.needsPricing) { + arrayAppend(templatesNeedingPrices, { + "templateId": tmpl.id, + "templateName": tmpl.name, + "appliesTo": tmpl.appliesTo + }); + } + } + + if (arrayLen(templatesNeedingPrices)) { + arrayAppend(questions, { + "type": "modifier_pricing_needed", + "question": "These add-on options need prices:", + "templates": templatesNeedingPrices + }); + } + + response.OK = true; + response.analyzedMenu = { + "categories": analyzedCategories, + "detectedTemplates": templateArray, + "totalItems": 0, + "totalTemplates": arrayLen(templateArray), + "totalModifierLinks": 0 + }; + + // Count totals + totalItems = 0; + totalLinks = 0; + for (cat in analyzedCategories) { + totalItems += arrayLen(cat.items); + for (item in cat.items) { + totalLinks += arrayLen(item.detectedModifiers); + } + } + response.analyzedMenu.totalItems = totalItems; + response.analyzedMenu.totalModifierLinks = totalLinks; + + response.questions = questions; + response.summary = "Analyzed " & totalItems & " items, detected " & arrayLen(templateArray) & " modifier templates with " & totalLinks & " links"; + +} catch (any e) { + response.error = e.message; + if (len(e.detail)) { + response.errorDetail = e.detail; + } +} + +writeOutput(serializeJSON(response)); + diff --git a/api/setup/bigdeans_import.json b/api/setup/bigdeans_import.json new file mode 100644 index 0000000..2a94a57 --- /dev/null +++ b/api/setup/bigdeans_import.json @@ -0,0 +1,341 @@ +{ + "business": { + "name": "Big Dean's Ocean Front Cafe", + "address": "1615 Ocean Front Walk", + "city": "Santa Monica", + "state": "CA", + "zip": "90401", + "phone": "(310) 393-2666", + "email": "mack@payfrit.com", + "ownerPhone": "9494442935", + "website": "https://bigdeansoceanfrontcafe.com", + "logoUrl": "https://bigdeansoceanfrontcafe.com/wp-content/uploads/2025/04/Logo_Smaller_92x87.png", + "headerUrl": "https://bigdeansoceanfrontcafe.com/wp-content/uploads/2025/04/TopBanner___BIGDEANS___.jpg" + }, + "modifierTemplates": [ + { + "id": "wing_style", + "name": "Wing Style", + "required": true, + "maxSelections": 1, + "options": [ + { "name": "Bone-In", "price": 0, "isDefault": true }, + { "name": "Boneless", "price": 0, "isDefault": false } + ] + }, + { + "id": "size_regular_small", + "name": "Size", + "required": true, + "maxSelections": 1, + "options": [ + { "name": "Regular", "price": 0, "isDefault": true }, + { "name": "Small", "price": 0, "isDefault": false } + ] + }, + { + "id": "size_full_half", + "name": "Size", + "required": true, + "maxSelections": 1, + "options": [ + { "name": "Full Order", "price": 0, "isDefault": true }, + { "name": "Half Order", "price": 0, "isDefault": false } + ] + }, + { + "id": "protein_addon", + "name": "Add Protein", + "required": false, + "maxSelections": 1, + "options": [ + { "name": "Plain", "price": 0, "isDefault": true }, + { "name": "With Chicken", "price": 0, "isDefault": false }, + { "name": "With Steak or Shrimp", "price": 0, "isDefault": false } + ] + }, + { + "id": "guacamole_addon", + "name": "Guacamole", + "required": false, + "maxSelections": 1, + "options": [ + { "name": "No Guacamole", "price": 0, "isDefault": true }, + { "name": "With Guacamole", "price": 0, "isDefault": false } + ] + }, + { + "id": "hotdog_extras", + "name": "Extras", + "required": false, + "maxSelections": 0, + "options": [ + { "name": "Add Sauerkraut", "price": 0.50, "isDefault": false }, + { "name": "Add Cheese", "price": 0.50, "isDefault": false } + ] + }, + { + "id": "chili_fries_extras", + "name": "Extras", + "required": false, + "maxSelections": 0, + "options": [ + { "name": "Add Cheese", "price": 0, "isDefault": false }, + { "name": "Add Onions", "price": 0, "isDefault": false } + ] + }, + { + "id": "salad_protein", + "name": "Add Protein", + "required": false, + "maxSelections": 1, + "options": [ + { "name": "No Protein", "price": 0, "isDefault": true }, + { "name": "Chicken Breast or Burger Patty", "price": 0, "isDefault": false }, + { "name": "Grilled Shrimp", "price": 0, "isDefault": false }, + { "name": "Grilled Mahi Mahi", "price": 0, "isDefault": false } + ] + }, + { + "id": "salad_dressing", + "name": "Dressing", + "required": true, + "maxSelections": 1, + "options": [ + { "name": "Italian", "price": 0, "isDefault": false }, + { "name": "Ranch", "price": 0, "isDefault": true }, + { "name": "Balsamic", "price": 0, "isDefault": false }, + { "name": "Caesar", "price": 0, "isDefault": false }, + { "name": "Blue Cheese", "price": 0, "isDefault": false }, + { "name": "Thousand Island", "price": 0, "isDefault": false }, + { "name": "French", "price": 0, "isDefault": false } + ] + }, + { + "id": "sides_choice", + "name": "Sauce/Side", + "required": false, + "maxSelections": 1, + "options": [ + { "name": "Sour Cream", "price": 0.50, "isDefault": false }, + { "name": "Jalapenos", "price": 0.50, "isDefault": false }, + { "name": "BBQ Sauce", "price": 0.50, "isDefault": false }, + { "name": "Ranch", "price": 0.50, "isDefault": false }, + { "name": "Blue Cheese", "price": 0.50, "isDefault": false }, + { "name": "Mayo", "price": 0.50, "isDefault": false }, + { "name": "Aioli", "price": 0.50, "isDefault": false } + ] + }, + { + "id": "onion_style", + "name": "Onions", + "required": false, + "maxSelections": 1, + "options": [ + { "name": "No Onions", "price": 0, "isDefault": false }, + { "name": "Grilled Onions", "price": 0, "isDefault": true }, + { "name": "Raw Onions", "price": 0, "isDefault": false } + ] + } + ], + "categories": [ + { + "name": "World Famous Burgers", + "categoryNote": "All burgers served with lettuce, tomato, Big Dean's sauce, and pickle spear. Grilled or raw onions available.", + "items": [ + { "name": "Big Dean's Burger", "description": "The burger that made Santa Monica famous!", "price": 0, "modifiers": ["onion_style"] }, + { "name": "Single Beef Burger with Cheese", "description": "", "price": 0, "modifiers": ["onion_style"] }, + { "name": "Single Beef Burger", "description": "", "price": 0, "modifiers": ["onion_style"] }, + { "name": "Beyond Burger", "description": "Vegan burger patty w/ lettuce, tomato, and pickle spear", "price": 0, "modifiers": ["onion_style"] }, + { "name": "Garden Burger", "description": "Vegetable patty prepared in the style of our burgers", "price": 0, "modifiers": ["onion_style"] }, + { "name": "Chili Size Burger", "description": "Beef burger served open faced topped with all beef chili, cheese, and onions", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Snacks and Sides", + "categoryNote": "Sides: Sour Cream, Jalapenos, BBQ sauce, Ranch, Blue Cheese, Mayo, or Aioli - $0.50 each", + "items": [ + { "name": "Buffalo Wings", "description": "Boneless or bone in", "price": 0, "modifiers": ["wing_style"] }, + { "name": "Frings", "description": "1/2 French fries and 1/2 onion rings", "price": 0, "modifiers": [] }, + { "name": "French Fries", "description": "Small available", "price": 0, "modifiers": ["size_regular_small"] }, + { "name": "Onion Rings", "description": "Small available", "price": 0, "modifiers": ["size_regular_small"] }, + { "name": "Chili Fries", "description": "Add cheese and onions", "price": 0, "modifiers": ["chili_fries_extras"] }, + { "name": "Chicken Fingers and Fries", "description": "1/2 order available", "price": 0, "modifiers": ["size_full_half"] }, + { "name": "Chips and Salsa", "description": "With guacamole option", "price": 0, "modifiers": ["guacamole_addon"] }, + { "name": "Cheese Quesadilla", "description": "With Chicken / With Steak or Shrimp options", "price": 0, "modifiers": ["protein_addon"] }, + { "name": "Cheese Nachos", "description": "With Chicken / With Steak or Shrimp options", "price": 0, "modifiers": ["protein_addon"] }, + { "name": "Chili Cheese Nachos", "description": "", "price": 0, "modifiers": [] }, + { "name": "Mozzarella Sticks", "description": "", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Sandwiches", + "items": [ + { "name": "Cajun Mahi Mahi", "description": "Lettuce, tomato, aioli, and guacamole", "price": 0, "modifiers": [] }, + { "name": "Philly Cheese Steak", "description": "Grilled peppers and onions", "price": 0, "modifiers": [] }, + { "name": "Cajun Chicken", "description": "Lettuce, tomato, and spicy aioli", "price": 0, "modifiers": [] }, + { "name": "Turkey", "description": "Lettuce, tomato, and mayo", "price": 0, "modifiers": [] }, + { "name": "Turkey Club", "description": "Bacon, lettuce, tomato, and mayo", "price": 0, "modifiers": [] }, + { "name": "Grilled Cheese", "description": "Sourdough and American cheese", "price": 0, "modifiers": [] }, + { "name": "Grilled Ham and Cheese", "description": "", "price": 0, "modifiers": [] }, + { "name": "Grilled Chicken", "description": "Lettuce, tomato, and mayo", "price": 0, "modifiers": [] }, + { "name": "BBQ Chicken", "description": "Lettuce, tomato, and BBQ sauce", "price": 0, "modifiers": [] }, + { "name": "Fried Chicken", "description": "Lettuce, tomato, and mayo", "price": 0, "modifiers": [] }, + { "name": "Chicken Caesar Wrap", "description": "", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Soups & Salads", + "categoryNote": "Dressing selections: Italian, Ranch, Balsamic, Caesar, Blue Cheese, Thousand Island, French. Add to any salad: Chicken breast or burger patty, Grilled Shrimp, Grilled Mahi Mahi", + "items": [ + { "name": "New England Clam Chowder", "description": "", "price": 0, "modifiers": [] }, + { "name": "Chili Bowl", "description": "With Cheese and Onion", "price": 0, "modifiers": [] }, + { "name": "Beach Salad", "description": "Spring mix greens, tomatoes, cucumbers, feta cheese, and carrots served in a crisp flour tortilla shell", "price": 0, "modifiers": ["salad_dressing", "salad_protein"] }, + { "name": "Caesar Salad", "description": "Romaine lettuce, croutons, and Caesar dressing", "price": 0, "modifiers": ["salad_protein"] } + ] + }, + { + "name": "Tacos", + "items": [ + { "name": "Fish Tacos", "description": "Pico sauce, chips, lettuce, tomatoes", "price": 0, "modifiers": [] }, + { "name": "Chicken Tacos", "description": "Cheese, cabbage, and tomatoes on corn tortillas with a side of guacamole and salsa", "price": 0, "modifiers": [] }, + { "name": "Steak Fajita", "description": "Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce", "price": 0, "modifiers": [] }, + { "name": "Shrimp Fajita", "description": "Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Seafood", + "items": [ + { "name": "Fried Fantail Shrimp", "description": "", "price": 0, "modifiers": [] }, + { "name": "Clam Boat with Fries", "description": "", "price": 0, "modifiers": [] }, + { "name": "Fish and Chips", "description": "1/2 order available", "price": 0, "modifiers": ["size_full_half"] }, + { "name": "Grilled Shrimp & Baby Scallop Skewers", "description": "", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Hot Dogs & Such", + "items": [ + { "name": "Hot Dog", "description": "Topped with onions, tomatoes, and relish", "price": 0, "modifiers": ["hotdog_extras"] }, + { "name": "Corn Dog", "description": "", "price": 0, "modifiers": [] }, + { "name": "Chili Dog", "description": "Topped with all beef chili", "price": 0, "modifiers": ["hotdog_extras"] } + ] + }, + { + "name": "Draft Beer", + "items": [ + { "name": "Budweiser (20oz)", "description": "", "price": 12, "modifiers": [] }, + { "name": "Bud Light (20oz)", "description": "", "price": 12, "modifiers": [] }, + { "name": "Big Dean's Blonde (20oz)", "description": "", "price": 12, "modifiers": [] } + ] + }, + { + "name": "Premium Draft Beer", + "items": [ + { "name": "Big Wave (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Cerveza De La Playa (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Dos XX (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Goose Island (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Guinness (16oz/20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Longboard (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Mango Cart (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Pacifico (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Santa Monica Wit (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Shock Top (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Stella Artois (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Michelob Ultra (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Space Dust (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "805 Lager (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "Heineken (20oz)", "description": "", "price": 14, "modifiers": [] }, + { "name": "10 Hop Hazy (20oz)", "description": "", "price": 14, "modifiers": [] } + ] + }, + { + "name": "Tall Cans", + "items": [ + { "name": "Coors", "description": "", "price": 0, "modifiers": [] }, + { "name": "Coors Light", "description": "", "price": 0, "modifiers": [] }, + { "name": "Tecate", "description": "", "price": 0, "modifiers": [] }, + { "name": "Michelob Ultra", "description": "", "price": 0, "modifiers": [] }, + { "name": "Busch Light", "description": "", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Bottle Beer", + "items": [ + { "name": "Budweiser", "description": "", "price": 0, "modifiers": [] }, + { "name": "Bud Light", "description": "", "price": 0, "modifiers": [] }, + { "name": "Coors Light", "description": "", "price": 0, "modifiers": [] }, + { "name": "High Life", "description": "", "price": 0, "modifiers": [] }, + { "name": "Miller Lite", "description": "", "price": 0, "modifiers": [] }, + { "name": "Michelob Ultra", "description": "", "price": 0, "modifiers": [] }, + { "name": "Corona / Corona Premier", "description": "", "price": 0, "modifiers": [] }, + { "name": "Pacifico", "description": "", "price": 0, "modifiers": [] }, + { "name": "Firestone Union Jack", "description": "", "price": 0, "modifiers": [] }, + { "name": "Amstel", "description": "", "price": 0, "modifiers": [] }, + { "name": "Newcastle Brown", "description": "", "price": 0, "modifiers": [] }, + { "name": "Big Noise", "description": "", "price": 0, "modifiers": [] }, + { "name": "Best Coast Cider 16oz", "description": "", "price": 0, "modifiers": [] }, + { "name": "Stella Cidre", "description": "", "price": 0, "modifiers": [] }, + { "name": "Bud 00 (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] }, + { "name": "Becks N/A (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] }, + { "name": "Heineken 00 (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] }, + { "name": "Guinness 0 (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] }, + { "name": "Mango Cart N/A (Non-Alcoholic)", "description": "", "price": 0, "modifiers": [] }, + { "name": "Shandy Santa Monica", "description": "", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Seltzers", + "items": [ + { "name": "High Noon", "description": "", "price": 0, "modifiers": [] }, + { "name": "White Claw", "description": "", "price": 0, "modifiers": [] }, + { "name": "Long Drink", "description": "", "price": 0, "modifiers": [] }, + { "name": "Topo Chico", "description": "", "price": 0, "modifiers": [] }, + { "name": "Suncruiser", "description": "", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Cocktails", + "items": [ + { "name": "Margarita", "description": "", "price": 0, "modifiers": [] }, + { "name": "Mai Tai", "description": "", "price": 0, "modifiers": [] }, + { "name": "Tequila Sunrise", "description": "", "price": 0, "modifiers": [] }, + { "name": "Paloma", "description": "", "price": 0, "modifiers": [] }, + { "name": "Espresso Martini", "description": "", "price": 0, "modifiers": [] }, + { "name": "Bloody Mary", "description": "", "price": 0, "modifiers": [] }, + { "name": "Green Tea Shot", "description": "", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Wine", + "items": [ + { "name": "Cabernet, Woodbridge", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Merlot, Woodbridge", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Sauvignon Blanc, Woodbridge", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Pinot Grigio, Woodbridge", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Hidden Sea Rose", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Chardonnay, Kendall Jackson", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Cabernet, Kendall Jackson", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Pinot Gris, Kendall Jackson", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Sauvignon Blanc, La Crema", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Murphy Goode Rose", "description": "Glass / Bottle", "price": 0, "modifiers": [] }, + { "name": "Sparkling Wine Splits", "description": "Single serving", "price": 0, "modifiers": [] }, + { "name": "Mimosa", "description": "Glass only", "price": 0, "modifiers": [] }, + { "name": "Freixenet", "description": "Bottle only", "price": 0, "modifiers": [] }, + { "name": "Mumm", "description": "Bottle only", "price": 0, "modifiers": [] } + ] + }, + { + "name": "Non-Alcoholic", + "items": [ + { "name": "Soda By the Can", "description": "", "price": 0, "modifiers": [] }, + { "name": "Bottled Water", "description": "", "price": 0, "modifiers": [] }, + { "name": "Sparkling Water", "description": "", "price": 0, "modifiers": [] }, + { "name": "Coffee or Hot Tea", "description": "", "price": 0, "modifiers": [] }, + { "name": "Juice", "description": "Orange, Cranberry, or Apple", "price": 0, "modifiers": [] } + ] + } + ], + "ownerUserID": 2, + "dryRun": false +} diff --git a/api/setup/downloadImages.cfm b/api/setup/downloadImages.cfm new file mode 100644 index 0000000..9f93f0f --- /dev/null +++ b/api/setup/downloadImages.cfm @@ -0,0 +1,150 @@ + + + + + + +/** + * Download Business Images + * + * Downloads logo and header images from external URLs and saves + * them to the uploads directory for a business. + * + * POST JSON: + * { + * "businessID": 123, + * "logoUrl": "https://example.com/logo.png", + * "headerUrl": "https://example.com/header.jpg" + * } + * + * Images are saved to: + * /uploads/logos/{BusinessID}.png + * /uploads/headers/{BusinessID}.jpg + */ + +response = { "OK": false, "downloaded": [] }; + +try { + requestBody = toString(getHttpRequestData().content); + if (!len(requestBody)) { + throw(message="No request body provided"); + } + + data = deserializeJSON(requestBody); + + if (!structKeyExists(data, "businessID") || val(data.businessID) == 0) { + throw(message="businessID is required"); + } + + businessID = val(data.businessID); + uploadsPath = expandPath("/uploads"); + + // Create uploads directories if they don't exist + logosPath = uploadsPath & "/logos"; + headersPath = uploadsPath & "/headers"; + + if (!directoryExists(logosPath)) { + directoryCreate(logosPath); + } + if (!directoryExists(headersPath)) { + directoryCreate(headersPath); + } + + // Download logo + if (structKeyExists(data, "logoUrl") && len(data.logoUrl)) { + logoUrl = data.logoUrl; + + // Determine extension from URL or default to .png + ext = ".png"; + if (findNoCase(".jpg", logoUrl) || findNoCase(".jpeg", logoUrl)) { + ext = ".jpg"; + } else if (findNoCase(".gif", logoUrl)) { + ext = ".gif"; + } else if (findNoCase(".webp", logoUrl)) { + ext = ".webp"; + } + + logoFile = logosPath & "/" & businessID & ext; + + try { + cfhttp(url=logoUrl, method="GET", getasbinary="yes", timeout=30, result="logoResult"); + + if (logoResult.statusCode contains "200") { + fileWrite(logoFile, logoResult.fileContent); + arrayAppend(response.downloaded, { + "type": "logo", + "url": logoUrl, + "savedTo": "/uploads/logos/" & businessID & ext, + "size": len(logoResult.fileContent) + }); + } else { + arrayAppend(response.downloaded, { + "type": "logo", + "url": logoUrl, + "error": "HTTP " & logoResult.statusCode + }); + } + } catch (any e) { + arrayAppend(response.downloaded, { + "type": "logo", + "url": logoUrl, + "error": e.message + }); + } + } + + // Download header + if (structKeyExists(data, "headerUrl") && len(data.headerUrl)) { + headerUrl = data.headerUrl; + + // Determine extension from URL or default to .jpg + ext = ".jpg"; + if (findNoCase(".png", headerUrl)) { + ext = ".png"; + } else if (findNoCase(".gif", headerUrl)) { + ext = ".gif"; + } else if (findNoCase(".webp", headerUrl)) { + ext = ".webp"; + } + + headerFile = headersPath & "/" & businessID & ext; + + try { + cfhttp(url=headerUrl, method="GET", getasbinary="yes", timeout=30, result="headerResult"); + + if (headerResult.statusCode contains "200") { + fileWrite(headerFile, headerResult.fileContent); + arrayAppend(response.downloaded, { + "type": "header", + "url": headerUrl, + "savedTo": "/uploads/headers/" & businessID & ext, + "size": len(headerResult.fileContent) + }); + } else { + arrayAppend(response.downloaded, { + "type": "header", + "url": headerUrl, + "error": "HTTP " & headerResult.statusCode + }); + } + } catch (any e) { + arrayAppend(response.downloaded, { + "type": "header", + "url": headerUrl, + "error": e.message + }); + } + } + + response.OK = true; + response.businessID = businessID; + +} catch (any e) { + response.error = e.message; + if (len(e.detail)) { + response.errorDetail = e.detail; + } +} + +writeOutput(serializeJSON(response)); + diff --git a/api/setup/importBusiness.cfm b/api/setup/importBusiness.cfm new file mode 100644 index 0000000..0f05ba0 --- /dev/null +++ b/api/setup/importBusiness.cfm @@ -0,0 +1,366 @@ + + + + + + +/** + * Import Business from Scraped Data + * + * Creates a complete business with: + * - Business record with contact info + * - Categories as Items (ParentID=0) + * - Menu items as children of categories + * - Modifier templates linked via ItemTemplateLinks + * - Modifier options as children of templates + * + * POST JSON structure: + * { + * "business": { + * "name": "Big Dean's Ocean Front Cafe", + * "address": "1615 Ocean Front Walk", + * "city": "Santa Monica", + * "state": "CA", + * "zip": "90401", + * "phone": "(310) 393-2666", + * "email": "owner@bigdeans.com", + * "ownerPhone": "3101234567", + * "website": "https://bigdeansoceanfrontcafe.com", + * "logoUrl": "https://...", + * "headerUrl": "https://...", + * "hours": { + * "mon": "12:00 PM - 7:00 PM", + * "tue": "11:00 AM - 8:00 PM", + * ... + * } + * }, + * "categories": [ + * { + * "name": "World Famous Burgers", + * "sortOrder": 1, + * "items": [ + * { + * "name": "Big Dean's Burger", + * "description": "The burger that made Santa Monica famous!", + * "price": 12.99, + * "modifiers": ["onion_style", "add_cheese"] // template references + * } + * ] + * } + * ], + * "modifierTemplates": [ + * { + * "id": "onion_style", + * "name": "Onions", + * "required": true, + * "maxSelections": 1, + * "options": [ + * { "name": "No Onions", "price": 0, "isDefault": false }, + * { "name": "Grilled Onions", "price": 0, "isDefault": true }, + * { "name": "Raw Onions", "price": 0, "isDefault": false } + * ] + * } + * ], + * "ownerUserID": 2, // optional, defaults to 1 + * "dryRun": false // optional, if true just validates without inserting + * } + */ + +response = { "OK": false, "steps": [], "errors": [], "warnings": [] }; + +try { + requestBody = toString(getHttpRequestData().content); + if (!len(requestBody)) { + throw(message="No request body provided"); + } + + data = deserializeJSON(requestBody); + dryRun = structKeyExists(data, "dryRun") && data.dryRun == true; + + if (dryRun) { + response.steps.append("DRY RUN MODE - no changes will be made"); + } + + // Validate required fields + if (!structKeyExists(data, "business") || !structKeyExists(data.business, "name")) { + throw(message="business.name is required"); + } + + biz = data.business; + ownerUserID = structKeyExists(data, "ownerUserID") ? val(data.ownerUserID) : 1; + + // Step 1: Create or find business + response.steps.append("Step 1: Creating business record..."); + + qCheck = queryExecute(" + SELECT BusinessID FROM Businesses WHERE BusinessName = :name + ", { name: biz.name }, { datasource: "payfrit" }); + + if (qCheck.recordCount > 0) { + BusinessID = qCheck.BusinessID; + response.steps.append("Business already exists with ID: " & BusinessID); + response.warnings.append("Existing business found - will add to existing menu"); + } else if (!dryRun) { + queryExecute(" + INSERT INTO Businesses (BusinessName, BusinessUserID, BusinessAddressID, BusinessDeliveryZipCodes, BusinessAddedOn) VALUES (:name, :ownerID, 0, '', NOW()) + ", { + name: biz.name, ownerID: ownerUserID + }, { datasource: "payfrit" }); + + qNew = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); + BusinessID = qNew.id; + response.steps.append("Created business with ID: " & BusinessID); + } else { + BusinessID = 0; + response.steps.append("Would create business: " & biz.name); + } + + // Step 2: Create modifier templates first (they need to exist before items reference them) + response.steps.append("Step 2: Creating modifier templates..."); + + templateMap = {}; // Maps template string ID to database ItemID + templates = structKeyExists(data, "modifierTemplates") ? data.modifierTemplates : []; + + for (tmpl in templates) { + templateStringID = tmpl.id; + templateName = tmpl.name; + required = structKeyExists(tmpl, "required") && tmpl.required == true; + maxSelections = structKeyExists(tmpl, "maxSelections") ? val(tmpl.maxSelections) : 0; + + if (!dryRun) { + // Check if template already exists for this business + qTmpl = queryExecute(" + SELECT ItemID FROM Items + WHERE ItemBusinessID = :bizID + AND ItemName = :name + AND ItemParentItemID = 0 + AND ItemID IN (SELECT TemplateItemID FROM ItemTemplateLinks) + ", { bizID: BusinessID, name: templateName }, { datasource: "payfrit" }); + + if (qTmpl.recordCount > 0) { + templateItemID = qTmpl.ItemID; + response.steps.append("Template exists: " & templateName & " (ID: " & templateItemID & ")"); + } else { + // Create template as Item at ParentID=0, with ItemIsCollapsible=1 to mark it as a template + // This ensures the API filter excludes it from categories + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, ItemName, ItemParentItemID, ItemPrice, + ItemIsActive, ItemRequiresChildSelection, ItemMaxNumSelectionReq, ItemSortOrder, ItemIsCollapsible + ) VALUES ( + :bizID, :name, 0, 0, 1, :required, :maxSelect, 0, 1 + ) + ", { + bizID: BusinessID, + name: templateName, + required: required ? 1 : 0, + maxSelect: maxSelections + }, { datasource: "payfrit" }); + + qNewTmpl = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); + templateItemID = qNewTmpl.id; + response.steps.append("Created template: " & templateName & " (ID: " & templateItemID & ")"); + } + + templateMap[templateStringID] = templateItemID; + + // Create template options + options = structKeyExists(tmpl, "options") ? tmpl.options : []; + optionOrder = 1; + for (opt in options) { + optName = opt.name; + optPrice = structKeyExists(opt, "price") ? val(opt.price) : 0; + optDefault = structKeyExists(opt, "isDefault") && opt.isDefault == true; + + qOpt = queryExecute(" + SELECT ItemID FROM Items + WHERE ItemBusinessID = :bizID AND ItemName = :name AND ItemParentItemID = :parentID + ", { bizID: BusinessID, name: optName, parentID: templateItemID }, { datasource: "payfrit" }); + + if (qOpt.recordCount == 0) { + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, ItemName, ItemParentItemID, ItemPrice, + ItemIsActive, ItemIsCheckedByDefault, ItemSortOrder + ) VALUES ( + :bizID, :name, :parentID, :price, 1, :isDefault, :sortOrder + ) + ", { + bizID: BusinessID, + name: optName, + parentID: templateItemID, + price: optPrice, + isDefault: optDefault ? 1 : 0, + sortOrder: optionOrder + }, { datasource: "payfrit" }); + } + optionOrder++; + } + } else { + response.steps.append("Would create template: " & templateName & " with " & arrayLen(tmpl.options) & " options"); + templateMap[templateStringID] = 0; + } + } + + // Step 3: Create categories (as Items at ParentID=0) + response.steps.append("Step 3: Creating categories..."); + + categoryMap = {}; // Maps category name to ItemID + categories = structKeyExists(data, "categories") ? data.categories : []; + catOrder = 1; + + for (cat in categories) { + catName = cat.name; + + if (!dryRun) { + // Check if category exists (Item at ParentID=0, not a template) + qCat = queryExecute(" + SELECT ItemID FROM Items + WHERE ItemBusinessID = :bizID + AND ItemName = :name + AND ItemParentItemID = 0 + AND ItemID NOT IN (SELECT TemplateItemID FROM ItemTemplateLinks) + ", { bizID: BusinessID, name: catName }, { datasource: "payfrit" }); + + if (qCat.recordCount > 0) { + categoryItemID = qCat.ItemID; + response.steps.append("Category exists: " & catName); + } else { + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, ItemName, ItemParentItemID, ItemPrice, + ItemIsActive, ItemSortOrder + ) VALUES ( + :bizID, :name, 0, 0, 1, :sortOrder + ) + ", { + bizID: BusinessID, + name: catName, + sortOrder: catOrder + }, { datasource: "payfrit" }); + + qNewCat = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); + categoryItemID = qNewCat.id; + response.steps.append("Created category: " & catName & " (ID: " & categoryItemID & ")"); + } + + categoryMap[catName] = categoryItemID; + } else { + response.steps.append("Would create category: " & catName); + categoryMap[catName] = 0; + } + catOrder++; + } + + // Step 4: Create menu items as children of categories + response.steps.append("Step 4: Creating menu items..."); + + totalItems = 0; + totalLinks = 0; + + for (cat in categories) { + catName = cat.name; + categoryItemID = categoryMap[catName]; + items = structKeyExists(cat, "items") ? cat.items : []; + itemOrder = 1; + + for (item in items) { + itemName = item.name; + itemDesc = structKeyExists(item, "description") ? item.description : ""; + itemPrice = structKeyExists(item, "price") ? val(item.price) : 0; + + if (!dryRun) { + // Check if item exists + qItem = queryExecute(" + SELECT ItemID FROM Items + WHERE ItemBusinessID = :bizID AND ItemName = :name AND ItemParentItemID = :parentID + ", { bizID: BusinessID, name: itemName, parentID: categoryItemID }, { datasource: "payfrit" }); + + if (qItem.recordCount > 0) { + menuItemID = qItem.ItemID; + } else { + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, ItemName, ItemDescription, ItemParentItemID, + ItemPrice, ItemIsActive, ItemSortOrder + ) VALUES ( + :bizID, :name, :desc, :parentID, :price, 1, :sortOrder + ) + ", { + bizID: BusinessID, + name: itemName, + desc: itemDesc, + parentID: categoryItemID, + price: itemPrice, + sortOrder: itemOrder + }, { datasource: "payfrit" }); + + qNewItem = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); + menuItemID = qNewItem.id; + } + + // Link modifier templates to this item + modifiers = structKeyExists(item, "modifiers") ? item.modifiers : []; + modOrder = 1; + for (modRef in modifiers) { + if (structKeyExists(templateMap, modRef)) { + templateItemID = templateMap[modRef]; + + // Check if link exists + qLink = queryExecute(" + SELECT 1 FROM ItemTemplateLinks + WHERE ItemID = :itemID AND TemplateItemID = :templateID + ", { itemID: menuItemID, templateID: templateItemID }, { datasource: "payfrit" }); + + if (qLink.recordCount == 0) { + queryExecute(" + INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder) + VALUES (:itemID, :templateID, :sortOrder) + ", { + itemID: menuItemID, + templateID: templateItemID, + sortOrder: modOrder + }, { datasource: "payfrit" }); + totalLinks++; + } + modOrder++; + } else { + response.warnings.append("Unknown modifier reference: " & modRef & " on item: " & itemName); + } + } + } + + totalItems++; + itemOrder++; + } + } + + response.steps.append("Created " & totalItems & " menu items with " & totalLinks & " template links"); + + // Summary + response.OK = true; + response.summary = { + "businessID": BusinessID, + "businessName": biz.name, + "categoriesCreated": arrayLen(categories), + "templatesCreated": arrayLen(templates), + "itemsCreated": totalItems, + "templateLinksCreated": totalLinks, + "dryRun": dryRun + }; + + if (dryRun) { + response.steps.append("DRY RUN COMPLETE - no changes were made"); + } else { + response.steps.append("IMPORT COMPLETE!"); + } + +} catch (any e) { + response.errors.append(e.message); + if (len(e.detail)) { + response.errors.append(e.detail); + } +} + +writeOutput(serializeJSON(response)); + diff --git a/api/setup/reimportBigDeans.cfm b/api/setup/reimportBigDeans.cfm new file mode 100644 index 0000000..24ea666 --- /dev/null +++ b/api/setup/reimportBigDeans.cfm @@ -0,0 +1,346 @@ + + + + + +// Big Dean's Ocean Front Cafe - Full Menu Reimport +// Based on actual menu image analysis + +bizId = 27; +dryRun = structKeyExists(url, "dryRun") ? true : false; + +actions = []; + +// ============================================================ +// STEP 1: Clear existing Big Dean's menu data +// ============================================================ +if (!dryRun) { + // Delete template links first + queryExecute("DELETE FROM ItemTemplateLinks WHERE ItemID IN (SELECT ItemID FROM Items WHERE ItemBusinessID = :bizId)", { bizId: bizId }); + // Delete all items + queryExecute("DELETE FROM Items WHERE ItemBusinessID = :bizId", { bizId: bizId }); +} +arrayAppend(actions, { step: "CLEAR", message: dryRun ? "Would clear existing data" : "Cleared existing data" }); + +// ============================================================ +// STEP 2: Create Modifier Templates (shared across items) +// ============================================================ + +// Helper function to insert item and return ID +function insertItem(name, description, parentId, price, isActive, isCheckedByDefault, requiresChild, maxSelections, isCollapsible, sortOrder) { + if (dryRun) return 0; + // Note: ItemIsActive is BIT(1) type, use b'1' or b'0' syntax + queryExecute(" + INSERT INTO Items (ItemBusinessID, ItemName, ItemDescription, ItemParentItemID, ItemPrice, + ItemIsActive, ItemIsCheckedByDefault, ItemRequiresChildSelection, + ItemMaxNumSelectionReq, ItemIsCollapsible, ItemSortOrder, ItemAddedOn) + VALUES (:bizId, :name, :desc, :parentId, :price, b'#isActive#', :isDefault, :reqChild, :maxSel, :isCollapse, :sort, NOW()) + ", { + bizId: bizId, name: name, desc: description, parentId: parentId, price: price, + isDefault: isCheckedByDefault, reqChild: requiresChild, + maxSel: maxSelections, isCollapse: isCollapsible, sort: sortOrder + }); + + qId = queryExecute("SELECT LAST_INSERT_ID() as id"); + return qId.id; +} + +// Helper to link template to item +function linkTemplate(menuItemId, templateId, sortOrder) { + if (dryRun) return; + queryExecute(" + INSERT INTO ItemTemplateLinks (ItemID, TemplateItemID, SortOrder) + VALUES (:itemId, :templateId, :sort) + ", { itemId: menuItemId, templateId: templateId, sort: sortOrder }); +} + +// ============================================================ +// MODIFIER TEMPLATES +// ============================================================ + +// --- Burger Toppings Template: Lettuce --- +tplLettuce = insertItem("Lettuce", "", 0, 0, 1, 0, 0, 1, 1, 1); +insertItem("Regular", "", tplLettuce, 0, 1, 1, 0, 0, 0, 1); +insertItem("Extra", "", tplLettuce, 0, 1, 0, 0, 0, 0, 2); +insertItem("Light", "", tplLettuce, 0, 1, 0, 0, 0, 0, 3); +insertItem("None", "", tplLettuce, 0, 1, 0, 0, 0, 0, 4); +arrayAppend(actions, { step: "TEMPLATE", name: "Lettuce", id: tplLettuce }); + +// --- Burger Toppings Template: Tomato --- +tplTomato = insertItem("Tomato", "", 0, 0, 1, 0, 0, 1, 1, 1); +insertItem("Regular", "", tplTomato, 0, 1, 1, 0, 0, 0, 1); +insertItem("Extra", "", tplTomato, 0, 1, 0, 0, 0, 0, 2); +insertItem("Light", "", tplTomato, 0, 1, 0, 0, 0, 0, 3); +insertItem("None", "", tplTomato, 0, 1, 0, 0, 0, 0, 4); +arrayAppend(actions, { step: "TEMPLATE", name: "Tomato", id: tplTomato }); + +// --- Burger Toppings Template: Big Dean's Sauce --- +tplSauce = insertItem("Big Dean's Sauce", "", 0, 0, 1, 0, 0, 1, 1, 1); +insertItem("Regular", "", tplSauce, 0, 1, 1, 0, 0, 0, 1); +insertItem("Extra", "", tplSauce, 0, 1, 0, 0, 0, 0, 2); +insertItem("Light", "", tplSauce, 0, 1, 0, 0, 0, 0, 3); +insertItem("None", "", tplSauce, 0, 1, 0, 0, 0, 0, 4); +arrayAppend(actions, { step: "TEMPLATE", name: "Big Dean's Sauce", id: tplSauce }); + +// --- Burger Toppings Template: Onions --- +tplOnions = insertItem("Onions", "", 0, 0, 1, 0, 0, 1, 1, 1); +insertItem("None", "", tplOnions, 0, 1, 1, 0, 0, 0, 1); +insertItem("Grilled", "", tplOnions, 0, 1, 0, 0, 0, 0, 2); +insertItem("Raw", "", tplOnions, 0, 1, 0, 0, 0, 0, 3); +arrayAppend(actions, { step: "TEMPLATE", name: "Onions", id: tplOnions }); + +// --- Burger Toppings Template: Pickle Spear --- +tplPickle = insertItem("Pickle Spear", "", 0, 0, 1, 0, 0, 1, 1, 1); +insertItem("None", "", tplPickle, 0, 1, 1, 0, 0, 0, 1); +insertItem("Add Pickle", "", tplPickle, 0, 1, 0, 0, 0, 0, 2); +arrayAppend(actions, { step: "TEMPLATE", name: "Pickle Spear", id: tplPickle }); + +// --- Wing Style Template --- +tplWingStyle = insertItem("Wing Style", "", 0, 0, 1, 0, 1, 1, 1, 1); +insertItem("Bone-In", "", tplWingStyle, 0, 1, 1, 0, 0, 0, 1); +insertItem("Boneless", "", tplWingStyle, 0, 1, 0, 0, 0, 0, 2); +arrayAppend(actions, { step: "TEMPLATE", name: "Wing Style", id: tplWingStyle }); + +// --- Size Template (Fries/Rings) --- +tplSize = insertItem("Size", "", 0, 0, 1, 0, 1, 1, 1, 1); +insertItem("Regular", "", tplSize, 0, 1, 1, 0, 0, 0, 1); +insertItem("Small", "", tplSize, 0, 1, 0, 0, 0, 0, 2); +arrayAppend(actions, { step: "TEMPLATE", name: "Size", id: tplSize }); + +// --- Portion Size Template (Half/Full) --- +tplPortion = insertItem("Portion", "", 0, 0, 1, 0, 1, 1, 1, 1); +insertItem("Full Order", "", tplPortion, 0, 1, 1, 0, 0, 0, 1); +insertItem("Half Order", "", tplPortion, 0, 1, 0, 0, 0, 0, 2); +arrayAppend(actions, { step: "TEMPLATE", name: "Portion", id: tplPortion }); + +// --- Sides Extras Template ($.50 each) --- +tplSideExtras = insertItem("Add Extras", "$.50 each", 0, 0, 1, 0, 0, 0, 0, 1); +insertItem("Sour Cream", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 1); +insertItem("Jalapenos", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 2); +insertItem("BBQ Sauce", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 3); +insertItem("Ranch", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 4); +insertItem("Blue Cheese", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 5); +insertItem("Mayo", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 6); +insertItem("Aioli", "", tplSideExtras, 0.50, 1, 0, 0, 0, 0, 7); +arrayAppend(actions, { step: "TEMPLATE", name: "Add Extras", id: tplSideExtras }); + +// --- Guacamole Option --- +tplGuac = insertItem("Guacamole", "", 0, 0, 1, 0, 0, 1, 1, 1); +insertItem("Without Guacamole", "", tplGuac, 0, 1, 1, 0, 0, 0, 1); +insertItem("With Guacamole", "", tplGuac, 0, 1, 0, 0, 0, 0, 2); +arrayAppend(actions, { step: "TEMPLATE", name: "Guacamole", id: tplGuac }); + +// --- Quesadilla/Nachos Add-ins (multi-select checkboxes) --- +tplAddIns = insertItem("Add-ins", "", 0, 0, 1, 0, 0, 0, 0, 1); +insertItem("Chicken", "", tplAddIns, 0, 1, 0, 0, 0, 0, 1); +insertItem("Steak", "", tplAddIns, 0, 1, 0, 0, 0, 0, 2); +insertItem("Shrimp", "", tplAddIns, 0, 1, 0, 0, 0, 0, 3); +arrayAppend(actions, { step: "TEMPLATE", name: "Add-ins", id: tplAddIns }); + +// --- Chili Fries Extras --- +tplChiliExtras = insertItem("Extras", "", 0, 0, 1, 0, 0, 0, 0, 1); +insertItem("Add Cheese", "", tplChiliExtras, 0.50, 1, 0, 0, 0, 0, 1); +insertItem("Add Onions", "", tplChiliExtras, 0.50, 1, 0, 0, 0, 0, 2); +arrayAppend(actions, { step: "TEMPLATE", name: "Extras (Chili)", id: tplChiliExtras }); + +// --- Salad Dressing --- +tplDressing = insertItem("Dressing", "", 0, 0, 1, 0, 1, 1, 1, 1); +insertItem("Italian", "", tplDressing, 0, 1, 0, 0, 0, 0, 1); +insertItem("Ranch", "", tplDressing, 0, 1, 1, 0, 0, 0, 2); +insertItem("Balsamic", "", tplDressing, 0, 1, 0, 0, 0, 0, 3); +insertItem("Caesar", "", tplDressing, 0, 1, 0, 0, 0, 0, 4); +insertItem("Blue Cheese", "", tplDressing, 0, 1, 0, 0, 0, 0, 5); +insertItem("Thousand Island", "", tplDressing, 0, 1, 0, 0, 0, 0, 6); +insertItem("French", "", tplDressing, 0, 1, 0, 0, 0, 0, 7); +arrayAppend(actions, { step: "TEMPLATE", name: "Dressing", id: tplDressing }); + +// --- Salad Add Protein --- +tplSaladProtein = insertItem("Add Protein", "", 0, 0, 1, 0, 0, 1, 1, 1); +insertItem("No Protein", "", tplSaladProtein, 0, 1, 1, 0, 0, 0, 1); +insertItem("Chicken Breast or Burger Patty", "", tplSaladProtein, 0, 1, 0, 0, 0, 0, 2); +insertItem("Grilled Shrimp", "", tplSaladProtein, 0, 1, 0, 0, 0, 0, 3); +insertItem("Grilled Mahi Mahi", "", tplSaladProtein, 0, 1, 0, 0, 0, 0, 4); +arrayAppend(actions, { step: "TEMPLATE", name: "Add Protein (Salad)", id: tplSaladProtein }); + +// --- Hot Dog Extras --- +tplHotDogExtras = insertItem("Extras", "", 0, 0, 1, 0, 0, 0, 0, 1); +insertItem("Add Sauerkraut", "", tplHotDogExtras, 0.50, 1, 0, 0, 0, 0, 1); +insertItem("Add Cheese", "", tplHotDogExtras, 0.50, 1, 0, 0, 0, 0, 2); +arrayAppend(actions, { step: "TEMPLATE", name: "Extras (Hot Dog)", id: tplHotDogExtras }); + +// ============================================================ +// CATEGORIES AND MENU ITEMS +// ============================================================ + +// --- CATEGORY: World Famous Burgers --- +catBurgers = insertItem("World Famous Burgers", "", 0, 0, 1, 0, 0, 0, 0, 1); +arrayAppend(actions, { step: "CATEGORY", name: "World Famous Burgers", id: catBurgers }); + +burger1 = insertItem("Big Dean's Cheeseburger", "Double meat double cheese - The burger that made Santa Monica famous!", catBurgers, 0, 1, 0, 0, 0, 0, 1); +burger2 = insertItem("Single Beef Burger with Cheese", "", catBurgers, 0, 1, 0, 0, 0, 0, 2); +burger3 = insertItem("Single Beef Burger", "", catBurgers, 0, 1, 0, 0, 0, 0, 3); +burger4 = insertItem("Beyond Burger", "Vegan burger patty w/ lettuce, tomato, and pickle spear", catBurgers, 0, 1, 0, 0, 0, 0, 4); +burger5 = insertItem("Garden Burger", "Vegetable patty prepared in the style of our burgers", catBurgers, 0, 1, 0, 0, 0, 0, 5); +burger6 = insertItem("Chili Size Burger", "Beef burger served open faced topped with all beef chili, cheese, and onions", catBurgers, 0, 1, 0, 0, 0, 0, 6); + +// Link burger templates to all burgers +burgerIds = [burger1, burger2, burger3, burger4, burger5, burger6]; +for (bid in burgerIds) { + linkTemplate(bid, tplLettuce, 1); + linkTemplate(bid, tplTomato, 2); + linkTemplate(bid, tplSauce, 3); + linkTemplate(bid, tplOnions, 4); + linkTemplate(bid, tplPickle, 5); +} +arrayAppend(actions, { step: "ITEMS", category: "Burgers", count: 6 }); + +// --- CATEGORY: Snacks and Sides --- +catSides = insertItem("Snacks and Sides", "", 0, 0, 1, 0, 0, 0, 0, 2); +arrayAppend(actions, { step: "CATEGORY", name: "Snacks and Sides", id: catSides }); + +side1 = insertItem("Buffalo Wings", "", catSides, 0, 1, 0, 0, 0, 0, 1); +linkTemplate(side1, tplWingStyle, 1); +linkTemplate(side1, tplSideExtras, 2); + +side2 = insertItem("Frings", "1/2 French fries and 1/2 onion rings", catSides, 0, 1, 0, 0, 0, 0, 2); +linkTemplate(side2, tplSideExtras, 1); + +side3 = insertItem("French Fries", "", catSides, 0, 1, 0, 0, 0, 0, 3); +linkTemplate(side3, tplSize, 1); +linkTemplate(side3, tplSideExtras, 2); + +side4 = insertItem("Onion Rings", "", catSides, 0, 1, 0, 0, 0, 0, 4); +linkTemplate(side4, tplSize, 1); +linkTemplate(side4, tplSideExtras, 2); + +side5 = insertItem("Chili Fries", "", catSides, 0, 1, 0, 0, 0, 0, 5); +linkTemplate(side5, tplChiliExtras, 1); +linkTemplate(side5, tplSideExtras, 2); + +side6 = insertItem("Chicken Fingers and Fries", "", catSides, 0, 1, 0, 0, 0, 0, 6); +linkTemplate(side6, tplPortion, 1); +linkTemplate(side6, tplSideExtras, 2); + +side7 = insertItem("Chips and Salsa", "", catSides, 0, 1, 0, 0, 0, 0, 7); +linkTemplate(side7, tplGuac, 1); + +side8 = insertItem("Cheese Quesadilla", "", catSides, 0, 1, 0, 0, 0, 0, 8); +linkTemplate(side8, tplAddIns, 1); + +side9 = insertItem("Cheese Nachos", "", catSides, 0, 1, 0, 0, 0, 0, 9); +linkTemplate(side9, tplAddIns, 1); + +side10 = insertItem("Chili Cheese Nachos", "", catSides, 0, 1, 0, 0, 0, 0, 10); +side11 = insertItem("Mozzarella Sticks", "", catSides, 0, 1, 0, 0, 0, 0, 11); +linkTemplate(side11, tplSideExtras, 1); + +arrayAppend(actions, { step: "ITEMS", category: "Snacks and Sides", count: 11 }); + +// --- CATEGORY: Sandwiches --- +catSandwiches = insertItem("Sandwiches", "", 0, 0, 1, 0, 0, 0, 0, 3); +arrayAppend(actions, { step: "CATEGORY", name: "Sandwiches", id: catSandwiches }); + +insertItem("Cajun Mahi Mahi", "Lettuce, tomato, aioli, and guacamole", catSandwiches, 0, 1, 0, 0, 0, 0, 1); +insertItem("Philly Cheese Steak", "Grilled peppers and onions", catSandwiches, 0, 1, 0, 0, 0, 0, 2); +insertItem("Cajun Chicken", "Lettuce, tomato, and spicy aioli", catSandwiches, 0, 1, 0, 0, 0, 0, 3); +insertItem("Turkey", "Lettuce, tomato, and mayo", catSandwiches, 0, 1, 0, 0, 0, 0, 4); +insertItem("Turkey Club", "Bacon, lettuce, tomato, and mayo", catSandwiches, 0, 1, 0, 0, 0, 0, 5); +insertItem("Grilled Cheese", "Sourdough and American cheese", catSandwiches, 0, 1, 0, 0, 0, 0, 6); +insertItem("Grilled Ham and Cheese", "", catSandwiches, 0, 1, 0, 0, 0, 0, 7); +insertItem("Grilled Chicken", "Lettuce, tomato, and mayo", catSandwiches, 0, 1, 0, 0, 0, 0, 8); +insertItem("BBQ Chicken", "Lettuce, tomato, and BBQ sauce", catSandwiches, 0, 1, 0, 0, 0, 0, 9); +insertItem("Fried Chicken", "Lettuce, tomato, and mayo", catSandwiches, 0, 1, 0, 0, 0, 0, 10); +insertItem("Chicken Caesar Wrap", "", catSandwiches, 0, 1, 0, 0, 0, 0, 11); + +arrayAppend(actions, { step: "ITEMS", category: "Sandwiches", count: 11 }); + +// --- CATEGORY: Soups & Salads --- +catSoups = insertItem("Soups & Salads", "", 0, 0, 1, 0, 0, 0, 0, 4); +arrayAppend(actions, { step: "CATEGORY", name: "Soups & Salads", id: catSoups }); + +insertItem("New England Clam Chowder", "", catSoups, 0, 1, 0, 0, 0, 0, 1); +insertItem("Chili Bowl", "With Cheese and Onion", catSoups, 0, 1, 0, 0, 0, 0, 2); + +salad1 = insertItem("Beach Salad", "Spring mix greens, tomatoes, cucumbers, feta cheese, and carrots served in a crisp flour tortilla shell", catSoups, 0, 1, 0, 0, 0, 0, 3); +linkTemplate(salad1, tplDressing, 1); +linkTemplate(salad1, tplSaladProtein, 2); + +salad2 = insertItem("Caesar Salad", "Romaine lettuce, croutons, and Caesar dressing", catSoups, 0, 1, 0, 0, 0, 0, 4); +linkTemplate(salad2, tplSaladProtein, 1); + +arrayAppend(actions, { step: "ITEMS", category: "Soups & Salads", count: 4 }); + +// --- CATEGORY: Tacos --- +catTacos = insertItem("Tacos", "Served two per order on flour tortillas unless otherwise noted", 0, 0, 1, 0, 0, 0, 0, 5); +arrayAppend(actions, { step: "CATEGORY", name: "Tacos", id: catTacos }); + +insertItem("Grilled Chicken Tacos", "Taco sauce, cheese, lettuce, tomatoes, and ranch", catTacos, 0, 1, 0, 0, 0, 0, 1); +insertItem("Fried Fish Tacos", "Taco sauce, cheese, lettuce, tomatoes, and ranch", catTacos, 0, 1, 0, 0, 0, 0, 2); +insertItem("Mahi Mahi Tacos", "Cheese, cabbage, and tomatoes on corn tortillas with a side of guacamole and salsa", catTacos, 0, 1, 0, 0, 0, 0, 3); +insertItem("Steak Fajita Tacos", "Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce", catTacos, 0, 1, 0, 0, 0, 0, 4); +insertItem("Shrimp Fajita Tacos", "Grilled with peppers, onions, and a side of cheese, tomatoes, and lettuce", catTacos, 0, 1, 0, 0, 0, 0, 5); + +arrayAppend(actions, { step: "ITEMS", category: "Tacos", count: 5 }); + +// --- CATEGORY: Seafood --- +catSeafood = insertItem("Seafood", "", 0, 0, 1, 0, 0, 0, 0, 6); +arrayAppend(actions, { step: "CATEGORY", name: "Seafood", id: catSeafood }); + +insertItem("Fried Fantail Shrimp", "With Fries", catSeafood, 0, 1, 0, 0, 0, 0, 1); +insertItem("Clam Boat with Fries", "", catSeafood, 0, 1, 0, 0, 0, 0, 2); + +seafood3 = insertItem("Fish and Chips", "", catSeafood, 0, 1, 0, 0, 0, 0, 3); +linkTemplate(seafood3, tplPortion, 1); + +insertItem("Grilled Shrimp & Baby Scallop Skewers", "", catSeafood, 0, 1, 0, 0, 0, 0, 4); + +arrayAppend(actions, { step: "ITEMS", category: "Seafood", count: 4 }); + +// --- CATEGORY: Hot Dogs & Such --- +catHotDogs = insertItem("Hot Dogs & Such", "", 0, 0, 1, 0, 0, 0, 0, 7); +arrayAppend(actions, { step: "CATEGORY", name: "Hot Dogs & Such", id: catHotDogs }); + +hotdog1 = insertItem("Hot Dog", "Topped with onions, tomatoes, and relish", catHotDogs, 0, 1, 0, 0, 0, 0, 1); +linkTemplate(hotdog1, tplHotDogExtras, 1); + +insertItem("Corn Dog", "", catHotDogs, 0, 1, 0, 0, 0, 0, 2); + +hotdog3 = insertItem("Chili Dog", "Topped with all beef chili", catHotDogs, 0, 1, 0, 0, 0, 0, 3); +linkTemplate(hotdog3, tplHotDogExtras, 1); + +arrayAppend(actions, { step: "ITEMS", category: "Hot Dogs & Such", count: 3 }); + +// ============================================================ +// SUMMARY +// ============================================================ + +// Count totals +if (!dryRun) { + qCount = queryExecute("SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = :bizId", { bizId: bizId }); + totalItems = qCount.cnt; + + qTemplateCount = queryExecute("SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = :bizId AND ItemParentItemID = 0 AND ItemIsCollapsible = 1", { bizId: bizId }); + templateCount = qTemplateCount.cnt; + + qLinkCount = queryExecute(" + SELECT COUNT(*) as cnt FROM ItemTemplateLinks tl + JOIN Items i ON i.ItemID = tl.ItemID + WHERE i.ItemBusinessID = :bizId + ", { bizId: bizId }); + linkCount = qLinkCount.cnt; +} else { + totalItems = "N/A (dry run)"; + templateCount = "N/A (dry run)"; + linkCount = "N/A (dry run)"; +} + +writeOutput(serializeJSON({ + "OK": true, + "DryRun": dryRun, + "BusinessID": bizId, + "TotalItems": totalItems, + "TemplateCount": templateCount, + "TemplateLinkCount": linkCount, + "Actions": actions +})); + diff --git a/api/stations/list.cfm b/api/stations/list.cfm new file mode 100644 index 0000000..62d8520 --- /dev/null +++ b/api/stations/list.cfm @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + #serializeJSON(arguments.payload)# + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/portal/login.html b/portal/login.html new file mode 100644 index 0000000..3cb774f --- /dev/null +++ b/portal/login.html @@ -0,0 +1,415 @@ + + + + + + Login - Payfrit Business Portal + + + + + + + + + diff --git a/portal/menu-builder.html b/portal/menu-builder.html index 25f2a81..ead565b 100644 --- a/portal/menu-builder.html +++ b/portal/menu-builder.html @@ -652,6 +652,14 @@
+
-

Templates

-
-
-
📏
-
-
Size Options
-
Small, Medium, Large
-
-
-
-
🥩
-
-
Protein Options
-
Chicken, Beef, Veggie
-
-
-
-
🌡️
-
-
Temperature
-
Hot, Iced, Blended
-
-
+

Modifier Templates

+
+
Loading templates...

Quick Stats

@@ -791,6 +779,10 @@ Items: 0
+
+ Templates: + 0 +
Photos Missing: 0 @@ -899,6 +891,16 @@
+ +