/** * 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"); } // Clean control characters that Lucee's JSON parser can't handle requestBody = reReplace(requestBody, "[\x00-\x08\x0B\x0C\x0E-\x1F]", "", "all"); 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 colors (6-digit hex without #) bizBrandColor = structKeyExists(biz, "brandColor") && isSimpleValue(biz.brandColor) ? trim(biz.brandColor) : ""; bizBrandColor = reReplace(bizBrandColor, "^##?", ""); if (!reFind("^[0-9A-Fa-f]{6}$", bizBrandColor)) { bizBrandColor = ""; } bizBrandColorLight = structKeyExists(biz, "brandColorLight") && isSimpleValue(biz.brandColorLight) ? trim(biz.brandColorLight) : ""; bizBrandColorLight = reReplace(bizBrandColorLight, "^##?", ""); if (!reFind("^[0-9A-Fa-f]{6}$", bizBrandColorLight)) { bizBrandColorLight = ""; } // 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, BrandColorLight, AddedOn) VALUES (:name, :phone, :userId, :addressId, :deliveryZips, :communityMealType, :taxRate, :brandColor, :brandColorLight, 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) }, brandColorLight: { value: len(bizBrandColorLight) ? bizBrandColorLight : javaCast("null", ""), cfsqltype: "cf_sql_varchar", null: !len(bizBrandColorLight) } }, { 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 : []; tmplType = structKeyExists(tmpl, "type") && isSimpleValue(tmpl.type) ? tmpl.type : "select"; maxSel = (tmplType == "checkbox") ? 0 : 1; // 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, :maxSel, 0 ) ", { bizID: businessId, name: tmplName, required: required ? 1 : 0, maxSel: maxSel }, { 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; optSelected = structKeyExists(opt, "selected") && opt.selected == true ? 1 : 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, IsCheckedByDefault, SortOrder ) VALUES ( :bizID, :name, :parentID, 0, :price, 1, :checked, :sortOrder ) ", { bizID: businessId, name: optName, parentID: templateItemID, price: optPrice, checked: optSelected, 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));