Toast provides latitude/longitude in the location object. Extract in analyzeMenuUrl.cfm and pass through to saveWizard.cfm, which now includes lat/lng in the address INSERT. Skips the background Nominatim geocode when coordinates are already available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
837 lines
35 KiB
Text
837 lines
35 KiB
Text
<cfsetting showdebugoutput="false">
|
|
<cfsetting enablecfoutputonly="true">
|
|
<cfsetting requesttimeout="300">
|
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
|
|
|
<cfscript>
|
|
/**
|
|
* Save Wizard Data
|
|
*
|
|
* Takes the extracted menu data from the setup wizard and saves it to the database.
|
|
* This transforms the wizard format into the format expected by the import system.
|
|
*
|
|
* POST JSON:
|
|
* {
|
|
* "businessId": "existing-business-id",
|
|
* "data": {
|
|
* "business": { "name": "...", "address": "...", "phone": "...", "hours": "..." },
|
|
* "categories": [ { "name": "...", "itemCount": 0 } ],
|
|
* "modifiers": [ { "name": "...", "required": true, "options": [...] } ],
|
|
* "items": [ { "name": "...", "price": 0, "category": "...", "modifiers": [...] } ]
|
|
* }
|
|
* }
|
|
*/
|
|
|
|
response = { "OK": false, "steps": [], "errors": [] };
|
|
itemsDir = expandPath("/uploads/items");
|
|
|
|
// Helper function: Resize image maintaining aspect ratio, fitting within maxSize box
|
|
function resizeToFit(img, maxSize) {
|
|
w = imageGetWidth(img);
|
|
h = imageGetHeight(img);
|
|
if (w <= maxSize && h <= maxSize) return img;
|
|
|
|
if (w > h) {
|
|
newW = maxSize;
|
|
newH = int(h * (maxSize / w));
|
|
} else {
|
|
newH = maxSize;
|
|
newW = int(w * (maxSize / h));
|
|
}
|
|
imageResize(img, newW, newH, "highQuality");
|
|
return img;
|
|
}
|
|
|
|
// Helper function: Create square thumbnail (center crop)
|
|
function createSquareThumb(img, size) {
|
|
thumb = imageCopy(img, 0, 0, imageGetWidth(img), imageGetHeight(img));
|
|
w = imageGetWidth(thumb);
|
|
h = imageGetHeight(thumb);
|
|
|
|
// Resize so smallest dimension equals size
|
|
if (w > h) {
|
|
imageResize(thumb, "", size, "highQuality");
|
|
} else {
|
|
imageResize(thumb, size, "", "highQuality");
|
|
}
|
|
|
|
// Center crop to square
|
|
w = imageGetWidth(thumb);
|
|
h = imageGetHeight(thumb);
|
|
if (w > h) {
|
|
x = int((w - h) / 2);
|
|
imageCrop(thumb, x, 0, h, h);
|
|
} else if (h > w) {
|
|
y = int((h - w) / 2);
|
|
imageCrop(thumb, 0, y, w, w);
|
|
}
|
|
|
|
// Final resize to exact size
|
|
imageResize(thumb, size, size, "highQuality");
|
|
return thumb;
|
|
}
|
|
|
|
// Helper function: Download and save item image from URL
|
|
function downloadItemImage(itemID, imageUrl) {
|
|
try {
|
|
// Skip empty or invalid URLs
|
|
if (!len(trim(imageUrl))) return false;
|
|
|
|
// Make URL absolute if relative
|
|
if (left(imageUrl, 2) == "//") {
|
|
imageUrl = "https:" & imageUrl;
|
|
} else if (left(imageUrl, 1) == "/") {
|
|
// Can't resolve relative URL without base - skip
|
|
return false;
|
|
}
|
|
|
|
// Download the image
|
|
http url="#imageUrl#" method="GET" timeout="30" result="httpResult" {
|
|
httpparam type="header" name="User-Agent" value="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
|
|
}
|
|
|
|
if (httpResult.statusCode != "200 OK" && !httpResult.statusCode.startsWith("200")) {
|
|
return false;
|
|
}
|
|
|
|
// Check if it's actually an image
|
|
contentType = structKeyExists(httpResult.responseHeader, "Content-Type") ? httpResult.responseHeader["Content-Type"] : "";
|
|
if (!findNoCase("image", contentType)) {
|
|
return false;
|
|
}
|
|
|
|
// Write to temp file
|
|
tempFile = itemsDir & "/temp_" & itemID & "_" & createUUID() & ".tmp";
|
|
fileWrite(tempFile, httpResult.fileContent);
|
|
|
|
// Read as image
|
|
img = imageNew(tempFile);
|
|
|
|
// Create thumbnail (128x128 square for retina)
|
|
thumb = createSquareThumb(img, 128);
|
|
thumbPath = itemsDir & "/" & itemID & "_thumb.jpg";
|
|
imageWrite(thumb, thumbPath, 0.85);
|
|
|
|
// Create medium size (400px max)
|
|
medium = imageCopy(img, 0, 0, imageGetWidth(img), imageGetHeight(img));
|
|
medium = resizeToFit(medium, 400);
|
|
mediumPath = itemsDir & "/" & itemID & "_medium.jpg";
|
|
imageWrite(medium, mediumPath, 0.85);
|
|
|
|
// Save full size (max 1200px)
|
|
img = resizeToFit(img, 1200);
|
|
fullPath = itemsDir & "/" & itemID & ".jpg";
|
|
imageWrite(img, fullPath, 0.90);
|
|
|
|
// Clean up temp file
|
|
try { fileDelete(tempFile); } catch (any e) {}
|
|
|
|
return true;
|
|
} catch (any e) {
|
|
// Clean up temp file if it exists
|
|
if (isDefined("tempFile") && fileExists(tempFile)) {
|
|
try { fileDelete(tempFile); } catch (any e2) {}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try {
|
|
requestBody = toString(getHttpRequestData().content);
|
|
if (!len(requestBody)) {
|
|
throw(message="No request body provided");
|
|
}
|
|
|
|
data = deserializeJSON(requestBody);
|
|
|
|
businessId = structKeyExists(data, "businessId") ? val(data.businessId) : 0;
|
|
userId = structKeyExists(data, "userId") ? val(data.userId) : 0;
|
|
providedMenuId = structKeyExists(data, "menuId") ? val(data.menuId) : 0;
|
|
wizardData = structKeyExists(data, "data") ? data.data : {};
|
|
biz = structKeyExists(wizardData, "business") ? wizardData.business : {};
|
|
|
|
// If no businessId, create a new business
|
|
if (businessId == 0) {
|
|
response.steps.append("No businessId provided - creating new business");
|
|
|
|
bizName = structKeyExists(biz, "name") && isSimpleValue(biz.name) ? biz.name : "";
|
|
if (!len(bizName)) {
|
|
throw(message="Business name is required to create new business");
|
|
}
|
|
|
|
if (userId == 0) {
|
|
throw(message="userId is required to create new business");
|
|
}
|
|
|
|
// Extract phone number
|
|
bizPhone = structKeyExists(biz, "phone") && isSimpleValue(biz.phone) ? trim(biz.phone) : "";
|
|
|
|
// Extract tax rate (stored as decimal, e.g. 8.25% -> 0.0825)
|
|
bizTaxRate = structKeyExists(biz, "taxRatePercent") && isSimpleValue(biz.taxRatePercent) ? val(biz.taxRatePercent) / 100 : 0;
|
|
|
|
// Extract brand color (6-digit hex without #)
|
|
bizBrandColor = structKeyExists(biz, "brandColor") && isSimpleValue(biz.brandColor) ? trim(biz.brandColor) : "";
|
|
// Ensure it's a valid 6-digit hex (remove # if present)
|
|
bizBrandColor = reReplace(bizBrandColor, "^##?", "");
|
|
if (!reFind("^[0-9A-Fa-f]{6}$", bizBrandColor)) {
|
|
bizBrandColor = ""; // Invalid format, skip it
|
|
}
|
|
|
|
// Create address record first (use extracted address fields) - safely extract as simple values
|
|
addressLine1 = structKeyExists(biz, "addressLine1") && isSimpleValue(biz.addressLine1) ? trim(biz.addressLine1) : "";
|
|
city = structKeyExists(biz, "city") && isSimpleValue(biz.city) ? trim(biz.city) : "";
|
|
state = structKeyExists(biz, "state") && isSimpleValue(biz.state) ? trim(biz.state) : "";
|
|
zip = structKeyExists(biz, "zip") && isSimpleValue(biz.zip) ? trim(biz.zip) : "";
|
|
|
|
// Clean up city - remove trailing punctuation (commas, periods, etc.)
|
|
city = reReplace(city, "[,.\s]+$", "", "all");
|
|
|
|
// Look up state ID from state abbreviation
|
|
stateID = 0;
|
|
response.steps.append("State value received: '" & state & "' (len: " & len(state) & ")");
|
|
if (len(state)) {
|
|
qState = queryTimed("
|
|
SELECT ID FROM tt_States WHERE Abbreviation = :abbr
|
|
", { abbr: uCase(state) }, { datasource: "payfrit" });
|
|
|
|
response.steps.append("State lookup for '" & uCase(state) & "' found " & qState.recordCount & " records");
|
|
if (qState.recordCount > 0) {
|
|
stateID = qState.ID;
|
|
response.steps.append("Using stateID: " & stateID);
|
|
}
|
|
}
|
|
|
|
// Check if lat/lng provided (e.g. from Toast)
|
|
bizLat = structKeyExists(biz, "latitude") && isNumeric(biz.latitude) ? biz.latitude : 0;
|
|
bizLng = structKeyExists(biz, "longitude") && isNumeric(biz.longitude) ? biz.longitude : 0;
|
|
|
|
queryTimed("
|
|
INSERT INTO Addresses (Line1, City, StateID, ZIPCode, UserID, AddressTypeID, Latitude, Longitude, AddedOn)
|
|
VALUES (:line1, :city, :stateID, :zip, :userID, :typeID, :lat, :lng, NOW())
|
|
", {
|
|
line1: len(addressLine1) ? addressLine1 : "Address pending",
|
|
city: len(city) ? city : "",
|
|
stateID: { value = stateID > 0 ? stateID : javaCast("null", ""), cfsqltype = "cf_sql_integer", null = stateID == 0 },
|
|
zip: len(zip) ? zip : "",
|
|
userID: userId,
|
|
typeID: 2,
|
|
lat: { value = bizLat != 0 ? bizLat : javaCast("null", ""), cfsqltype = "cf_sql_decimal", null = bizLat == 0 },
|
|
lng: { value = bizLng != 0 ? bizLng : javaCast("null", ""), cfsqltype = "cf_sql_decimal", null = bizLng == 0 }
|
|
}, { datasource: "payfrit" });
|
|
|
|
qNewAddr = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
|
addressId = qNewAddr.id;
|
|
response.steps.append("Created address record (ID: " & addressId & ")");
|
|
|
|
// Auto-geocode in background only if lat/lng not already provided
|
|
if (bizLat == 0 || bizLng == 0) {
|
|
thread action="run" name="geocode_#addressId#" addressId=addressId {
|
|
include template="/api/inc/geocode.cfm";
|
|
geocodeAddressById(attributes.addressId);
|
|
}
|
|
}
|
|
|
|
// Get community meal type (1=provide meals, 2=food bank donation)
|
|
communityMealType = structKeyExists(wizardData, "communityMealType") && isSimpleValue(wizardData.communityMealType) ? val(wizardData.communityMealType) : 1;
|
|
if (communityMealType < 1 || communityMealType > 2) communityMealType = 1;
|
|
|
|
// Create new business with address link and phone
|
|
queryTimed("
|
|
INSERT INTO Businesses (Name, Phone, UserID, AddressID, DeliveryZIPCodes, CommunityMealType, TaxRate, BrandColor, AddedOn)
|
|
VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, :brandColor, NOW())
|
|
", {
|
|
name: bizName,
|
|
phone: bizPhone,
|
|
userId: userId,
|
|
addressId: addressId,
|
|
deliveryZips: len(zip) ? zip : "",
|
|
communityMealType: communityMealType,
|
|
taxRate: { value: bizTaxRate, cfsqltype: "cf_sql_decimal" },
|
|
brandColor: { value: len(bizBrandColor) ? bizBrandColor : javaCast("null", ""), cfsqltype: "cf_sql_varchar", null: !len(bizBrandColor) }
|
|
}, { datasource: "payfrit" });
|
|
|
|
qNewBiz = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
|
businessId = qNewBiz.id;
|
|
response.steps.append("Created new business: " & bizName & " (ID: " & businessId & ")");
|
|
|
|
// Update address with business ID link
|
|
queryTimed("
|
|
UPDATE Addresses SET BusinessID = :businessID WHERE ID = :addressID
|
|
", {
|
|
businessID: businessId,
|
|
addressID: addressId
|
|
}, { datasource: "payfrit" });
|
|
response.steps.append("Linked address to business");
|
|
|
|
// Create default task types for the business
|
|
// 1. Call Staff (notifications icon, purple)
|
|
// 2. Chat With Staff (chat icon, blue)
|
|
// 3. Pay With Cash (payments icon, green)
|
|
defaultTaskTypes = [
|
|
{ name: "Call Staff", icon: "notifications", color: "##9C27B0", description: "Request staff assistance" },
|
|
{ name: "Chat With Staff", icon: "chat", color: "##2196F3", description: "Open a chat conversation" },
|
|
{ name: "Pay With Cash", icon: "payments", color: "##4CAF50", description: "Request to pay with cash" },
|
|
{ name: "Deliver to Table", icon: "restaurant", color: "##FF9800", description: "Deliver completed order to table" },
|
|
{ name: "Order Ready for Pickup", icon: "shopping_bag", color: "##00BCD4", description: "Notify customer their order is ready" },
|
|
{ name: "Deliver to Address", icon: "local_shipping", color: "##795548", description: "Deliver order to customer address" }
|
|
];
|
|
|
|
for (tt = 1; tt <= arrayLen(defaultTaskTypes); tt++) {
|
|
taskType = defaultTaskTypes[tt];
|
|
queryTimed("
|
|
INSERT INTO tt_TaskTypes (Name, Description, Icon, Color, BusinessID, SortOrder)
|
|
VALUES (:name, :description, :icon, :color, :businessID, :sortOrder)
|
|
", {
|
|
name: { value: taskType.name, cfsqltype: "cf_sql_varchar" },
|
|
description: { value: taskType.description, cfsqltype: "cf_sql_varchar" },
|
|
icon: { value: taskType.icon, cfsqltype: "cf_sql_varchar" },
|
|
color: { value: taskType.color, cfsqltype: "cf_sql_varchar" },
|
|
businessID: { value: businessId, cfsqltype: "cf_sql_integer" },
|
|
sortOrder: { value: tt, cfsqltype: "cf_sql_integer" }
|
|
}, { datasource: "payfrit" });
|
|
}
|
|
response.steps.append("Created 6 default task types");
|
|
|
|
// Create default task categories for the business
|
|
defaultTaskCategories = [
|
|
{ name: "Service Point", color: "##F44336" }, // Red
|
|
{ name: "Kitchen", color: "##FF9800" }, // Orange
|
|
{ name: "Bar", color: "##9C27B0" }, // Purple
|
|
{ name: "Cleaning", color: "##4CAF50" }, // Green
|
|
{ name: "Management", color: "##2196F3" }, // Blue
|
|
{ name: "Delivery", color: "##00BCD4" }, // Cyan
|
|
{ name: "General", color: "##607D8B" } // Blue Grey
|
|
];
|
|
|
|
for (tc = 1; tc <= arrayLen(defaultTaskCategories); tc++) {
|
|
taskCat = defaultTaskCategories[tc];
|
|
queryTimed("
|
|
INSERT INTO TaskCategories (BusinessID, Name, Color)
|
|
VALUES (:businessID, :name, :color)
|
|
", {
|
|
businessID: { value: businessId, cfsqltype: "cf_sql_integer" },
|
|
name: { value: taskCat.name, cfsqltype: "cf_sql_varchar" },
|
|
color: { value: taskCat.color, cfsqltype: "cf_sql_varchar" }
|
|
}, { datasource: "payfrit" });
|
|
}
|
|
response.steps.append("Created 7 default task categories");
|
|
|
|
// Save business hours from structured schedule
|
|
if (structKeyExists(biz, "hoursSchedule") && isArray(biz.hoursSchedule)) {
|
|
hoursSchedule = biz.hoursSchedule;
|
|
response.steps.append("Processing " & arrayLen(hoursSchedule) & " days of hours");
|
|
|
|
for (i = 1; i <= arrayLen(hoursSchedule); i++) {
|
|
dayData = hoursSchedule[i];
|
|
if (!isStruct(dayData)) continue;
|
|
|
|
dayID = structKeyExists(dayData, "dayId") ? val(dayData.dayId) : 0;
|
|
openTime = structKeyExists(dayData, "open") && isSimpleValue(dayData.open) ? dayData.open : "09:00";
|
|
closeTime = structKeyExists(dayData, "close") && isSimpleValue(dayData.close) ? dayData.close : "17:00";
|
|
|
|
// Convert HH:MM to HH:MM:SS if needed
|
|
if (len(openTime) == 5) openTime = openTime & ":00";
|
|
if (len(closeTime) == 5) closeTime = closeTime & ":00";
|
|
|
|
// Insert hours record for this day
|
|
queryTimed("
|
|
INSERT INTO Hours (BusinessID, DayID, OpenTime, ClosingTime)
|
|
VALUES (:bizID, :dayID, :openTime, :closeTime)
|
|
", {
|
|
bizID: businessId,
|
|
dayID: dayID,
|
|
openTime: openTime,
|
|
closeTime: closeTime
|
|
}, { datasource: "payfrit" });
|
|
}
|
|
|
|
response.steps.append("Created " & arrayLen(hoursSchedule) & " hours records");
|
|
}
|
|
} else {
|
|
// Verify existing business exists
|
|
qBiz = queryTimed("
|
|
SELECT ID, Name AS BusinessName FROM Businesses WHERE ID = :id
|
|
", { id: businessId }, { datasource: "payfrit" });
|
|
|
|
if (qBiz.recordCount == 0) {
|
|
throw(message="Business not found: " & businessId);
|
|
}
|
|
|
|
response.steps.append("Found existing business: " & qBiz.BusinessName);
|
|
}
|
|
|
|
// Build modifier template map
|
|
// The wizard format has modifiers as simple objects, we need to create IDs
|
|
modTemplates = structKeyExists(wizardData, "modifiers") ? wizardData.modifiers : [];
|
|
templateMap = {}; // Maps modifier name to database ItemID
|
|
|
|
response.steps.append("Processing " & arrayLen(modTemplates) & " modifier templates...");
|
|
|
|
for (i = 1; i <= arrayLen(modTemplates); i++) {
|
|
tmpl = modTemplates[i];
|
|
tmplName = structKeyExists(tmpl, "name") && isSimpleValue(tmpl.name) ? tmpl.name : "";
|
|
if (!len(tmplName)) {
|
|
response.steps.append("Warning: Skipping modifier template with no name at index " & i);
|
|
continue;
|
|
}
|
|
required = structKeyExists(tmpl, "required") && tmpl.required == true;
|
|
options = structKeyExists(tmpl, "options") && isArray(tmpl.options) ? tmpl.options : [];
|
|
|
|
// Debug: Log options info
|
|
response.steps.append("Template '" & tmplName & "' has " & arrayLen(options) & " options (type: " & (isArray(options) ? "array" : "other") & ")");
|
|
|
|
// Check if template already exists for this business
|
|
qTmpl = queryTimed("
|
|
SELECT i.ID FROM Items i
|
|
WHERE i.BusinessID = :bizID
|
|
AND i.Name = :name
|
|
AND i.ParentItemID = 0
|
|
AND i.CategoryID = 0
|
|
", { bizID: businessId, name: tmplName }, { datasource: "payfrit" });
|
|
|
|
if (qTmpl.recordCount > 0) {
|
|
templateItemID = qTmpl.ID;
|
|
response.steps.append("Template exists: " & tmplName & " (ID: " & templateItemID & ")");
|
|
} else {
|
|
// Create template as Item with CategoryID=0 to mark as template
|
|
queryTimed("
|
|
INSERT INTO Items (
|
|
BusinessID, Name, ParentItemID, CategoryID, Price,
|
|
IsActive, RequiresChildSelection, MaxNumSelectionReq,
|
|
SortOrder
|
|
) VALUES (
|
|
:bizID, :name, 0, 0, 0, 1, :required, 1, 0
|
|
)
|
|
", {
|
|
bizID: businessId,
|
|
name: tmplName,
|
|
required: required ? 1 : 0
|
|
}, { datasource: "payfrit" });
|
|
|
|
qNewTmpl = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
|
templateItemID = qNewTmpl.id;
|
|
response.steps.append("Created template: " & tmplName & " (ID: " & templateItemID & ")");
|
|
}
|
|
|
|
templateMap[tmplName] = templateItemID;
|
|
|
|
// Create/update template options
|
|
optionOrder = 1;
|
|
for (j = 1; j <= arrayLen(options); j++) {
|
|
opt = options[j];
|
|
// Safety check: ensure opt is a struct with a name that's a simple value
|
|
if (!isStruct(opt)) continue;
|
|
if (!structKeyExists(opt, "name")) continue;
|
|
if (!isSimpleValue(opt.name)) continue;
|
|
if (!len(opt.name)) continue;
|
|
|
|
optName = opt.name;
|
|
optPrice = structKeyExists(opt, "price") && isSimpleValue(opt.price) ? val(opt.price) : 0;
|
|
|
|
qOpt = queryTimed("
|
|
SELECT ID FROM Items
|
|
WHERE BusinessID = :bizID AND Name = :name AND ParentItemID = :parentID
|
|
", { bizID: businessId, name: optName, parentID: templateItemID }, { datasource: "payfrit" });
|
|
|
|
if (qOpt.recordCount == 0) {
|
|
queryTimed("
|
|
INSERT INTO Items (
|
|
BusinessID, Name, ParentItemID, CategoryID,
|
|
Price, IsActive, SortOrder
|
|
) VALUES (
|
|
:bizID, :name, :parentID, 0, :price, 1, :sortOrder
|
|
)
|
|
", {
|
|
bizID: businessId,
|
|
name: optName,
|
|
parentID: templateItemID,
|
|
price: optPrice,
|
|
sortOrder: optionOrder
|
|
}, { datasource: "payfrit" });
|
|
}
|
|
optionOrder++;
|
|
}
|
|
}
|
|
|
|
// Use provided menuId (add-menu mode) or create/find menu
|
|
if (providedMenuId > 0) {
|
|
menuID = providedMenuId;
|
|
// Look up the menu name for logging
|
|
qName = queryTimed("SELECT Name FROM Menus WHERE ID = :id", { id: menuID }, { datasource: "payfrit" });
|
|
menuName = qName.recordCount > 0 ? qName.Name : "Menu " & menuID;
|
|
response.steps.append("Using provided menu: " & menuName & " (ID: " & menuID & ")");
|
|
} else {
|
|
// Create a Menu record for this business (or get existing menu with same name)
|
|
menuName = structKeyExists(wizardData, "menuName") && isSimpleValue(wizardData.menuName) && len(trim(wizardData.menuName))
|
|
? trim(wizardData.menuName)
|
|
: "Main Menu";
|
|
|
|
// Get menu time range (optional)
|
|
menuStartTime = structKeyExists(wizardData, "menuStartTime") && isSimpleValue(wizardData.menuStartTime) && len(trim(wizardData.menuStartTime))
|
|
? trim(wizardData.menuStartTime)
|
|
: "";
|
|
menuEndTime = structKeyExists(wizardData, "menuEndTime") && isSimpleValue(wizardData.menuEndTime) && len(trim(wizardData.menuEndTime))
|
|
? trim(wizardData.menuEndTime)
|
|
: "";
|
|
|
|
// Convert HH:MM to HH:MM:SS if needed
|
|
if (len(menuStartTime) == 5) menuStartTime = menuStartTime & ":00";
|
|
if (len(menuEndTime) == 5) menuEndTime = menuEndTime & ":00";
|
|
|
|
// Validate menu hours fall within business operating hours
|
|
if (len(menuStartTime) && len(menuEndTime)) {
|
|
qHours = queryTimed("
|
|
SELECT MIN(OpenTime) as earliestOpen, MAX(ClosingTime) as latestClose
|
|
FROM Hours
|
|
WHERE BusinessID = :bizID
|
|
", { bizID: businessId }, { datasource: "payfrit" });
|
|
|
|
if (qHours.recordCount > 0 && !isNull(qHours.earliestOpen) && !isNull(qHours.latestClose)) {
|
|
earliestOpen = timeFormat(qHours.earliestOpen, "HH:mm:ss");
|
|
latestClose = timeFormat(qHours.latestClose, "HH:mm:ss");
|
|
|
|
if (menuStartTime < earliestOpen || menuEndTime > latestClose) {
|
|
throw(message="Menu hours (" & menuStartTime & " - " & menuEndTime & ") must be within business operating hours (" & earliestOpen & " - " & latestClose & ")");
|
|
}
|
|
response.steps.append("Validated menu hours against business hours (" & earliestOpen & " - " & latestClose & ")");
|
|
}
|
|
}
|
|
|
|
qMenu = queryTimed("
|
|
SELECT ID FROM Menus
|
|
WHERE BusinessID = :bizID AND Name = :name AND IsActive = 1
|
|
", { bizID: businessId, name: menuName }, { datasource: "payfrit" });
|
|
|
|
if (qMenu.recordCount > 0) {
|
|
menuID = qMenu.ID;
|
|
// Update existing menu with new time range if provided
|
|
if (len(menuStartTime) && len(menuEndTime)) {
|
|
queryTimed("
|
|
UPDATE Menus SET StartTime = :startTime, EndTime = :endTime
|
|
WHERE ID = :menuID
|
|
", {
|
|
menuID: menuID,
|
|
startTime: menuStartTime,
|
|
endTime: menuEndTime
|
|
}, { datasource: "payfrit" });
|
|
response.steps.append("Updated existing menu: " & menuName & " (ID: " & menuID & ") with hours " & menuStartTime & " - " & menuEndTime);
|
|
} else {
|
|
response.steps.append("Using existing menu: " & menuName & " (ID: " & menuID & ")");
|
|
}
|
|
} else {
|
|
queryTimed("
|
|
INSERT INTO Menus (
|
|
BusinessID, Name, DaysActive, StartTime, EndTime, SortOrder, IsActive, AddedOn
|
|
) VALUES (
|
|
:bizID, :name, 127, :startTime, :endTime, 0, 1, NOW()
|
|
)
|
|
", {
|
|
bizID: businessId,
|
|
name: menuName,
|
|
startTime: len(menuStartTime) ? menuStartTime : javaCast("null", ""),
|
|
endTime: len(menuEndTime) ? menuEndTime : javaCast("null", "")
|
|
}, { datasource: "payfrit" });
|
|
|
|
qNewMenu = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
|
menuID = qNewMenu.id;
|
|
timeInfo = len(menuStartTime) && len(menuEndTime) ? " (" & menuStartTime & " - " & menuEndTime & ")" : " (all day)";
|
|
response.steps.append("Created menu: " & menuName & timeInfo & " (ID: " & menuID & ")");
|
|
}
|
|
}
|
|
|
|
// Build category map
|
|
categories = structKeyExists(wizardData, "categories") ? wizardData.categories : [];
|
|
categoryMap = {}; // Maps category name to CategoryID
|
|
|
|
response.steps.append("Processing " & arrayLen(categories) & " categories...");
|
|
|
|
// First pass: create top-level categories (no parentCategoryName)
|
|
catOrder = 1;
|
|
for (c = 1; c <= arrayLen(categories); c++) {
|
|
cat = categories[c];
|
|
catName = structKeyExists(cat, "name") && isSimpleValue(cat.name) ? cat.name : "";
|
|
if (!len(catName)) {
|
|
response.steps.append("Warning: Skipping category with no name at index " & c);
|
|
continue;
|
|
}
|
|
// Skip subcategories in first pass
|
|
if (structKeyExists(cat, "parentCategoryName") && len(trim(cat.parentCategoryName))) continue;
|
|
|
|
// Check if category exists in Categories table for this menu
|
|
qCat = queryTimed("
|
|
SELECT ID FROM Categories
|
|
WHERE BusinessID = :bizID AND Name = :name AND MenuID = :menuID
|
|
", { bizID: businessId, name: catName, menuID: menuID }, { datasource: "payfrit" });
|
|
|
|
if (qCat.recordCount > 0) {
|
|
categoryID = qCat.ID;
|
|
response.steps.append("Category exists: " & catName & " (ID: " & categoryID & ")");
|
|
} else {
|
|
queryTimed("
|
|
INSERT INTO Categories (
|
|
BusinessID, MenuID, Name, SortOrder
|
|
) VALUES (
|
|
:bizID, :menuID, :name, :sortOrder
|
|
)
|
|
", {
|
|
bizID: businessId,
|
|
menuID: menuID,
|
|
name: catName,
|
|
sortOrder: catOrder
|
|
}, { datasource: "payfrit" });
|
|
|
|
qNewCat = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
|
categoryID = qNewCat.id;
|
|
response.steps.append("Created category: " & catName & " in menu " & menuName & " (ID: " & categoryID & ")");
|
|
}
|
|
|
|
categoryMap[catName] = categoryID;
|
|
catOrder++;
|
|
}
|
|
|
|
// Second pass: create subcategories (have parentCategoryName)
|
|
for (c = 1; c <= arrayLen(categories); c++) {
|
|
cat = categories[c];
|
|
catName = structKeyExists(cat, "name") && isSimpleValue(cat.name) ? cat.name : "";
|
|
if (!len(catName)) continue;
|
|
parentName = structKeyExists(cat, "parentCategoryName") ? trim(cat.parentCategoryName) : "";
|
|
if (!len(parentName)) continue;
|
|
|
|
parentCatID = structKeyExists(categoryMap, parentName) ? categoryMap[parentName] : 0;
|
|
if (parentCatID == 0) {
|
|
response.steps.append("Warning: Parent category '" & parentName & "' not found for subcategory '" & catName & "'");
|
|
continue;
|
|
}
|
|
|
|
qCat = queryTimed("
|
|
SELECT ID FROM Categories
|
|
WHERE BusinessID = :bizID AND Name = :name AND MenuID = :menuID AND ParentCategoryID = :parentCatID
|
|
", { bizID: businessId, name: catName, menuID: menuID, parentCatID: parentCatID }, { datasource: "payfrit" });
|
|
|
|
if (qCat.recordCount > 0) {
|
|
categoryID = qCat.ID;
|
|
response.steps.append("Subcategory exists: " & catName & " under " & parentName & " (ID: " & categoryID & ")");
|
|
} else {
|
|
queryTimed("
|
|
INSERT INTO Categories (
|
|
BusinessID, MenuID, Name, ParentCategoryID, SortOrder
|
|
) VALUES (
|
|
:bizID, :menuID, :name, :parentCatID, :sortOrder
|
|
)
|
|
", {
|
|
bizID: businessId,
|
|
menuID: menuID,
|
|
name: catName,
|
|
parentCatID: parentCatID,
|
|
sortOrder: catOrder
|
|
}, { datasource: "payfrit" });
|
|
|
|
qNewCat = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
|
categoryID = qNewCat.id;
|
|
response.steps.append("Created subcategory: " & catName & " under " & parentName & " (ID: " & categoryID & ")");
|
|
}
|
|
|
|
categoryMap[catName] = categoryID;
|
|
catOrder++;
|
|
}
|
|
|
|
// Create menu items
|
|
items = structKeyExists(wizardData, "items") ? wizardData.items : [];
|
|
|
|
response.steps.append("Processing " & arrayLen(items) & " menu items...");
|
|
|
|
totalItems = 0;
|
|
totalLinks = 0;
|
|
totalImages = 0;
|
|
|
|
// Track item order within each category
|
|
categoryItemOrder = {};
|
|
|
|
// Track item name -> database ID mapping for image uploads
|
|
itemIdMap = {};
|
|
|
|
for (n = 1; n <= arrayLen(items); n++) {
|
|
item = items[n];
|
|
if (!isStruct(item)) continue;
|
|
|
|
// Safely extract all item fields - ensure they're simple values
|
|
itemName = structKeyExists(item, "name") && isSimpleValue(item.name) ? item.name : "";
|
|
if (!len(itemName)) {
|
|
response.steps.append("Warning: Skipping item with no name at index " & n);
|
|
continue;
|
|
}
|
|
|
|
itemDesc = "";
|
|
if (structKeyExists(item, "description") && isSimpleValue(item.description)) {
|
|
itemDesc = item.description;
|
|
}
|
|
|
|
itemPrice = 0;
|
|
if (structKeyExists(item, "price")) {
|
|
if (isSimpleValue(item.price)) {
|
|
itemPrice = val(item.price);
|
|
}
|
|
}
|
|
|
|
itemCategory = "";
|
|
if (structKeyExists(item, "category") && isSimpleValue(item.category)) {
|
|
itemCategory = item.category;
|
|
}
|
|
|
|
itemModifiers = structKeyExists(item, "modifiers") && isArray(item.modifiers) ? item.modifiers : [];
|
|
|
|
// Get category ID
|
|
if (!len(itemCategory) || !structKeyExists(categoryMap, itemCategory)) {
|
|
response.steps.append("Warning: Item '" & itemName & "' has unknown category - skipping");
|
|
continue;
|
|
}
|
|
|
|
categoryID = categoryMap[itemCategory];
|
|
|
|
// Track sort order within category
|
|
if (!structKeyExists(categoryItemOrder, itemCategory)) {
|
|
categoryItemOrder[itemCategory] = 1;
|
|
}
|
|
itemOrder = categoryItemOrder[itemCategory];
|
|
categoryItemOrder[itemCategory]++;
|
|
|
|
// Check if item exists
|
|
qItem = queryTimed("
|
|
SELECT ID FROM Items
|
|
WHERE BusinessID = :bizID
|
|
AND Name = :name
|
|
AND CategoryID = :catID
|
|
", { bizID: businessId, name: itemName, catID: categoryID }, { datasource: "payfrit" });
|
|
|
|
if (qItem.recordCount > 0) {
|
|
menuItemID = qItem.ID;
|
|
// Update existing item
|
|
queryTimed("
|
|
UPDATE Items SET
|
|
Description = :desc,
|
|
Price = :price,
|
|
SortOrder = :sortOrder
|
|
WHERE ID = :id
|
|
", {
|
|
desc: itemDesc,
|
|
price: itemPrice,
|
|
sortOrder: itemOrder,
|
|
id: menuItemID
|
|
}, { datasource: "payfrit" });
|
|
} else {
|
|
queryTimed("
|
|
INSERT INTO Items (
|
|
BusinessID, Name, Description, ParentItemID,
|
|
CategoryID, Price, IsActive, SortOrder
|
|
) VALUES (
|
|
:bizID, :name, :desc, 0, :catID, :price, 1, :sortOrder
|
|
)
|
|
", {
|
|
bizID: businessId,
|
|
name: itemName,
|
|
desc: itemDesc,
|
|
catID: categoryID,
|
|
price: itemPrice,
|
|
sortOrder: itemOrder
|
|
}, { datasource: "payfrit" });
|
|
|
|
qNewItem = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" });
|
|
menuItemID = qNewItem.id;
|
|
}
|
|
|
|
totalItems++;
|
|
|
|
// Track mapping for image uploads (use frontend ID if present, else name)
|
|
frontendId = structKeyExists(item, "id") && isSimpleValue(item.id) ? item.id : "";
|
|
if (len(frontendId)) {
|
|
itemIdMap[frontendId] = menuItemID;
|
|
}
|
|
itemIdMap[itemName] = menuItemID;
|
|
|
|
// Link modifier templates to this item
|
|
modOrder = 1;
|
|
for (m = 1; m <= arrayLen(itemModifiers); m++) {
|
|
modRef = itemModifiers[m];
|
|
// Handle both string modifier names and struct references
|
|
if (isSimpleValue(modRef)) {
|
|
modName = modRef;
|
|
} else if (isStruct(modRef) && structKeyExists(modRef, "name")) {
|
|
modName = modRef.name;
|
|
} else {
|
|
continue; // Skip invalid modifier reference
|
|
}
|
|
if (structKeyExists(templateMap, modName)) {
|
|
templateItemID = templateMap[modName];
|
|
|
|
// Check if link exists
|
|
qLink = queryTimed("
|
|
SELECT 1 FROM lt_ItemID_TemplateItemID
|
|
WHERE ItemID = :itemID AND TemplateItemID = :templateID
|
|
", { itemID: menuItemID, templateID: templateItemID }, { datasource: "payfrit" });
|
|
|
|
if (qLink.recordCount == 0) {
|
|
queryTimed("
|
|
INSERT INTO lt_ItemID_TemplateItemID (ItemID, TemplateItemID, SortOrder)
|
|
VALUES (:itemID, :templateID, :sortOrder)
|
|
", {
|
|
itemID: menuItemID,
|
|
templateID: templateItemID,
|
|
sortOrder: modOrder
|
|
}, { datasource: "payfrit" });
|
|
totalLinks++;
|
|
}
|
|
modOrder++;
|
|
}
|
|
}
|
|
|
|
// Download item image if URL provided
|
|
itemImageUrl = "";
|
|
if (structKeyExists(item, "imageUrl") && isSimpleValue(item.imageUrl)) {
|
|
itemImageUrl = trim(item.imageUrl);
|
|
}
|
|
if (len(itemImageUrl)) {
|
|
if (downloadItemImage(menuItemID, itemImageUrl)) {
|
|
totalImages++;
|
|
}
|
|
}
|
|
}
|
|
|
|
response.steps.append("Created/updated " & totalItems & " items with " & totalLinks & " modifier links" & (totalImages > 0 ? " and " & totalImages & " images downloaded" : ""));
|
|
|
|
response.OK = true;
|
|
response.summary = {
|
|
"businessId": businessId,
|
|
"categoriesProcessed": arrayLen(categories),
|
|
"templatesProcessed": arrayLen(modTemplates),
|
|
"itemsProcessed": totalItems,
|
|
"linksCreated": totalLinks,
|
|
"imagesDownloaded": totalImages,
|
|
"itemIdMap": itemIdMap
|
|
};
|
|
|
|
// Clean up temp folder from ZIP upload if provided
|
|
tempFolder = structKeyExists(data, "tempFolder") && isSimpleValue(data.tempFolder) ? trim(data.tempFolder) : "";
|
|
if (len(tempFolder)) {
|
|
// Validate folder name is safe (alphanumeric only - UUID without dashes)
|
|
if (reFind("^[a-f0-9]{32}$", tempFolder)) {
|
|
tempFolderPath = expandPath("/temp/menu-import/" & tempFolder);
|
|
if (directoryExists(tempFolderPath)) {
|
|
try {
|
|
directoryDelete(tempFolderPath, true);
|
|
response.steps.append("Cleaned up temp folder: " & tempFolder);
|
|
} catch (any cleanupErr) {
|
|
response.steps.append("Warning: Could not delete temp folder: " & cleanupErr.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (any e) {
|
|
response.errors.append(e.message);
|
|
if (len(e.detail)) {
|
|
response.errors.append(e.detail);
|
|
}
|
|
}
|
|
|
|
writeOutput(serializeJSON(response));
|
|
</cfscript>
|