diff --git a/api/Application.cfm b/api/Application.cfm index 7fc28b3..dd48d83 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -2,7 +2,7 @@ - + @@ -95,6 +95,7 @@ if (len(request._api_path)) { if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/get.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/businesses/getChildren.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/update.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/updateHours.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/servicepoints/list.cfm", request._api_path)) request._api_isPublic = true; @@ -109,6 +110,7 @@ if (len(request._api_path)) { if (findNoCase("/api/beacons/list_all.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/getBusinessFromBeacon.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/reassign_all.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/beacons/lookup.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/assignments/list.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/assignments/save.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/assignments/delete.cfm", request._api_path)) request._api_isPublic = true; @@ -173,6 +175,8 @@ if (len(request._api_path)) { 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/menu/uploadHeader.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/menu/listCategories.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/menu/saveCategory.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/saveBrandColor.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true; diff --git a/api/admin/addCategoryScheduleFields.cfm b/api/admin/addCategoryScheduleFields.cfm new file mode 100644 index 0000000..f5be694 --- /dev/null +++ b/api/admin/addCategoryScheduleFields.cfm @@ -0,0 +1,75 @@ + + + + + + +/** + * Add Schedule Fields to Categories Table + * + * Adds time-based scheduling fields: + * - CategoryScheduleStart: TIME - Start time when category is available (e.g., 06:00:00 for breakfast) + * - CategoryScheduleEnd: TIME - End time when category stops being available (e.g., 11:00:00) + * - CategoryScheduleDays: VARCHAR(20) - Comma-separated list of day IDs (1=Sun, 2=Mon, etc.) or NULL for all days + * + * Run this once to migrate the schema. + */ + +response = { "OK": false }; + +try { + // Check if columns already exist + qCheck = queryExecute(" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'payfrit' + AND TABLE_NAME = 'Categories' + AND COLUMN_NAME IN ('CategoryScheduleStart', 'CategoryScheduleEnd', 'CategoryScheduleDays') + ", {}, { datasource: "payfrit" }); + + existingCols = valueList(qCheck.COLUMN_NAME); + + added = []; + + // Add CategoryScheduleStart if not exists + if (!listFindNoCase(existingCols, "CategoryScheduleStart")) { + queryExecute(" + ALTER TABLE Categories + ADD COLUMN CategoryScheduleStart TIME NULL + ", {}, { datasource: "payfrit" }); + arrayAppend(added, "CategoryScheduleStart"); + } + + // Add CategoryScheduleEnd if not exists + if (!listFindNoCase(existingCols, "CategoryScheduleEnd")) { + queryExecute(" + ALTER TABLE Categories + ADD COLUMN CategoryScheduleEnd TIME NULL + ", {}, { datasource: "payfrit" }); + arrayAppend(added, "CategoryScheduleEnd"); + } + + // Add CategoryScheduleDays if not exists + if (!listFindNoCase(existingCols, "CategoryScheduleDays")) { + queryExecute(" + ALTER TABLE Categories + ADD COLUMN CategoryScheduleDays VARCHAR(20) NULL + ", {}, { datasource: "payfrit" }); + arrayAppend(added, "CategoryScheduleDays"); + } + + response["OK"] = true; + response["ColumnsAdded"] = added; + response["AlreadyExisted"] = listToArray(existingCols); + response["MESSAGE"] = arrayLen(added) > 0 + ? "Added #arrayLen(added)# column(s): #arrayToList(added)#" + : "All columns already exist"; + +} catch (any e) { + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/addLatLng.cfm b/api/admin/addLatLng.cfm new file mode 100644 index 0000000..983cda6 --- /dev/null +++ b/api/admin/addLatLng.cfm @@ -0,0 +1,30 @@ + + + + + +try { + // Check if columns already exist + checkCols = queryExecute( + "SHOW COLUMNS FROM Addresses LIKE 'AddressLat'", + [], + { datasource = "payfrit" } + ); + + if (checkCols.recordCount EQ 0) { + // Add the columns + queryExecute( + "ALTER TABLE Addresses + ADD COLUMN AddressLat DECIMAL(10,7) NULL, + ADD COLUMN AddressLng DECIMAL(10,7) NULL", + [], + { datasource = "payfrit" } + ); + writeOutput(serializeJSON({ "OK": true, "MESSAGE": "Columns added successfully" })); + } else { + writeOutput(serializeJSON({ "OK": true, "MESSAGE": "Columns already exist" })); + } +} catch (any e) { + writeOutput(serializeJSON({ "OK": false, "ERROR": e.message })); +} + diff --git a/api/admin/createMenusTable.cfm b/api/admin/createMenusTable.cfm new file mode 100644 index 0000000..c93596e --- /dev/null +++ b/api/admin/createMenusTable.cfm @@ -0,0 +1,85 @@ + + + + + + +/** + * Create Menus table for multiple menu support + * + * Menus can have: + * - Name (e.g., "Lunch Menu", "Dinner Menu", "Happy Hour") + * - Active days (bitmask linked to business hours days) + * - Start/End times for when the menu is available + * - Sort order for display + */ + +response = { "OK": false }; + +try { + // Check if Menus table exists + qCheck = queryExecute(" + SELECT 1 FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = 'payfrit' + AND TABLE_NAME = 'Menus' + ", {}, { datasource: "payfrit" }); + + if (qCheck.recordCount > 0) { + response["OK"] = true; + response["MESSAGE"] = "Menus table already exists"; + } else { + // Create Menus table + queryExecute(" + CREATE TABLE Menus ( + MenuID INT AUTO_INCREMENT PRIMARY KEY, + MenuBusinessID INT NOT NULL, + MenuName VARCHAR(100) NOT NULL, + MenuDescription VARCHAR(500) NULL, + MenuDaysActive INT NOT NULL DEFAULT 127, + MenuStartTime TIME NULL, + MenuEndTime TIME NULL, + MenuSortOrder INT NOT NULL DEFAULT 0, + MenuIsActive TINYINT NOT NULL DEFAULT 1, + MenuAddedOn DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_menus_business (MenuBusinessID), + INDEX idx_menus_active (MenuBusinessID, MenuIsActive) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + ", {}, { datasource: "payfrit" }); + + response["OK"] = true; + response["MESSAGE"] = "Menus table created successfully"; + response["SCHEMA"] = { + "MenuDaysActive": "Bitmask: 1=Sun, 2=Mon, 4=Tue, 8=Wed, 16=Thu, 32=Fri, 64=Sat (127 = all days)" + }; + } + + // Check if CategoryMenuID column exists in Categories table + qCatCol = queryExecute(" + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'payfrit' + AND TABLE_NAME = 'Categories' + AND COLUMN_NAME = 'CategoryMenuID' + ", {}, { datasource: "payfrit" }); + + if (qCatCol.recordCount == 0) { + // Add CategoryMenuID column to Categories table + queryExecute(" + ALTER TABLE Categories + ADD COLUMN CategoryMenuID INT NULL DEFAULT NULL AFTER CategoryBusinessID, + ADD INDEX idx_categories_menu (CategoryMenuID) + ", {}, { datasource: "payfrit" }); + response["CATEGORIES_UPDATED"] = true; + response["CATEGORIES_MESSAGE"] = "Added CategoryMenuID column to Categories table"; + } else { + response["CATEGORIES_UPDATED"] = false; + response["CATEGORIES_MESSAGE"] = "CategoryMenuID column already exists"; + } + +} catch (any e) { + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; + response["DETAIL"] = e.detail ?: ""; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/createParentBusiness.cfm b/api/admin/createParentBusiness.cfm new file mode 100644 index 0000000..cec0003 --- /dev/null +++ b/api/admin/createParentBusiness.cfm @@ -0,0 +1,119 @@ + + + + + + +/** + * Create Parent Business (Shell) + * + * Creates a minimal business record to serve as a parent for other businesses. + * No menu items, categories, or hours required. + * + * POST body: + * { + * "BusinessName": "Century Casino", + * "UserID": 1, + * "ChildBusinessIDs": [47, 48] // Optional: link existing businesses as children + * } + * + * Returns the new BusinessID + */ + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(raw)) == 0) return {}; + try { + var data = deserializeJSON(raw); + return isStruct(data) ? data : {}; + } catch (any e) { + return {}; + } +} + +response = { "OK": false }; + +try { + data = readJsonBody(); + + BusinessName = structKeyExists(data, "BusinessName") ? trim(data.BusinessName) : ""; + UserID = structKeyExists(data, "UserID") ? val(data.UserID) : 0; + ChildBusinessIDs = structKeyExists(data, "ChildBusinessIDs") && isArray(data.ChildBusinessIDs) ? data.ChildBusinessIDs : []; + + if (!len(BusinessName)) { + response["ERROR"] = "missing_name"; + response["MESSAGE"] = "BusinessName is required"; + writeOutput(serializeJSON(response)); + abort; + } + + if (UserID <= 0) { + response["ERROR"] = "missing_userid"; + response["MESSAGE"] = "UserID is required"; + writeOutput(serializeJSON(response)); + abort; + } + + // Create minimal address record (just a placeholder) + queryExecute(" + INSERT INTO Addresses (AddressLine1, AddressUserID, AddressTypeID, AddressAddedOn) + VALUES ('Parent Business - No Physical Location', :userID, 2, NOW()) + ", { + userID: UserID + }, { datasource = "payfrit" }); + + qAddr = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource = "payfrit" }); + addressId = qAddr.id; + + // Create parent business (no menu, no hours, just a shell) + queryExecute(" + INSERT INTO Businesses (BusinessName, BusinessUserID, BusinessAddressID, BusinessParentBusinessID, BusinessDeliveryZipCodes, BusinessAddedOn) + VALUES (:name, :userId, :addressId, NULL, '', NOW()) + ", { + name: BusinessName, + userId: UserID, + addressId: addressId + }, { datasource = "payfrit" }); + + qBiz = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource = "payfrit" }); + newBusinessID = qBiz.id; + + // Link address back to business + queryExecute(" + UPDATE Addresses SET AddressBusinessID = :bizId WHERE AddressID = :addrId + ", { + bizId: newBusinessID, + addrId: addressId + }, { datasource = "payfrit" }); + + // Update child businesses if provided + linkedChildren = []; + for (childID in ChildBusinessIDs) { + childID = val(childID); + if (childID > 0) { + queryExecute(" + UPDATE Businesses SET BusinessParentBusinessID = :parentId WHERE BusinessID = :childId + ", { + parentId: newBusinessID, + childId: childID + }, { datasource = "payfrit" }); + arrayAppend(linkedChildren, childID); + } + } + + response["OK"] = true; + response["BusinessID"] = newBusinessID; + response["BusinessName"] = BusinessName; + response["MESSAGE"] = "Parent business created"; + if (arrayLen(linkedChildren) > 0) { + response["LinkedChildren"] = linkedChildren; + } + +} catch (any e) { + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/deleteBusiness.cfm b/api/admin/deleteBusiness.cfm new file mode 100644 index 0000000..e2a1f3c --- /dev/null +++ b/api/admin/deleteBusiness.cfm @@ -0,0 +1,55 @@ + + + + + +/** + * Delete a business (and its address) + * POST: { "BusinessID": 52 } + */ + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(raw)) == 0) return {}; + try { + return deserializeJSON(raw); + } catch (any e) { + return {}; + } +} + +response = { "OK": false }; + +try { + data = readJsonBody(); + bizID = val(data.BusinessID ?: 0); + + if (bizID <= 0) { + response["ERROR"] = "BusinessID required"; + } else { + // Get address ID first + qBiz = queryExecute("SELECT BusinessAddressID FROM Businesses WHERE BusinessID = :id", { id: bizID }, { datasource = "payfrit" }); + + if (qBiz.recordCount == 0) { + response["ERROR"] = "Business not found"; + } else { + addrID = qBiz.BusinessAddressID; + + // Delete business + queryExecute("DELETE FROM Businesses WHERE BusinessID = :id", { id: bizID }, { datasource = "payfrit" }); + + // Delete address if exists + if (val(addrID) > 0) { + queryExecute("DELETE FROM Addresses WHERE AddressID = :id", { id: addrID }, { datasource = "payfrit" }); + } + + response["OK"] = true; + response["MESSAGE"] = "Deleted BusinessID " & bizID; + } + } +} catch (any e) { + response["ERROR"] = e.message; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/admin/geocode.cfm b/api/admin/geocode.cfm new file mode 100644 index 0000000..37e3b74 --- /dev/null +++ b/api/admin/geocode.cfm @@ -0,0 +1,204 @@ + + + + + Address Geocoding + + + +

Address Geocoding

+

Auto-geocode addresses using OpenStreetMap Nominatim (free, no API key required)

+ + +function geocodeAddress(addressString) { + var result = { "success": false, "error": "" }; + if (len(trim(addressString)) EQ 0) { + result.error = "Empty address"; + return result; + } + + try { + var httpService = new http(); + httpService.setMethod("GET"); + httpService.setUrl("https://nominatim.openstreetmap.org/search?q=" & urlEncodedFormat(addressString) & "&format=json&limit=1"); + httpService.addParam(type="header", name="User-Agent", value="Payfrit/1.0"); + httpService.setTimeout(10); + + var httpResult = httpService.send().getPrefix(); + + if (httpResult.statusCode CONTAINS "200") { + var data = deserializeJSON(httpResult.fileContent); + if (arrayLen(data) GT 0) { + result.success = true; + result.lat = data[1].lat; + result.lng = data[1].lon; + return result; + } + result.error = "No results found"; + return result; + } + result.error = "HTTP " & httpResult.statusCode; + return result; + } catch (any e) { + result.error = e.message; + return result; + } +} + +function buildAddressString(line1, line2, city, zipCode) { + var parts = []; + if (len(trim(line1))) arrayAppend(parts, trim(line1)); + if (len(trim(line2))) arrayAppend(parts, trim(line2)); + if (len(trim(city))) arrayAppend(parts, trim(city)); + if (len(trim(zipCode))) arrayAppend(parts, trim(zipCode)); + return arrayToList(parts, ", "); +} + + + + + + SELECT AddressLine1, AddressLine2, AddressCity, AddressZIPCode + FROM Addresses WHERE AddressID = + + + + + + + UPDATE Addresses SET AddressLat = , + AddressLng = + WHERE AddressID = + +
Geocoded Address ID #addressId#: #geo.lat#, #geo.lng#
+ +
Failed: #geo.error#
+
+
+
+ + + + SELECT AddressID, AddressLine1, AddressLine2, AddressCity, AddressZIPCode + FROM Addresses + WHERE (AddressLat IS NULL OR AddressLat = 0) + AND AddressLine1 IS NOT NULL AND AddressLine1 != '' + + + + + + + + + UPDATE Addresses SET AddressLat = , + AddressLng = + WHERE AddressID = + + + + + + + +
Geocoded #successCount# addresses. #failCount# failed.
+
+ + + SELECT + b.BusinessID, + b.BusinessName, + a.AddressID, + a.AddressLine1, + a.AddressLine2, + a.AddressCity, + a.AddressZIPCode, + a.AddressLat, + a.AddressLng + FROM Businesses b + LEFT JOIN Addresses a ON b.BusinessAddressID = a.AddressID + ORDER BY b.BusinessName + + + + + + + + + + +

+ #missingCount# addresses missing coordinates. + + + (~#missingCount# seconds due to rate limiting) + +

+ + + + + + + + + + + + + + + + + + + + +
BusinessAddressCoordinatesAction
+ #addresses.BusinessName# + + #chr(10003)# + + #chr(9679)# + + + + #addresses.AddressLine1#, #addresses.AddressLine2#
+ #addresses.AddressCity# #addresses.AddressZIPCode# + + No address +
+
+ + #numberFormat(addresses.AddressLat, "_.______")#
+ #numberFormat(addresses.AddressLng, "_.______")# + + - +
+
+ + + +
+
+ + + diff --git a/api/admin/linkChildBusiness.cfm b/api/admin/linkChildBusiness.cfm new file mode 100644 index 0000000..0e13d17 --- /dev/null +++ b/api/admin/linkChildBusiness.cfm @@ -0,0 +1,46 @@ + + + + + +/** + * Link a child business to a parent + * POST: { "ChildBusinessID": 49, "ParentBusinessID": 51 } + */ + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(raw)) == 0) return {}; + try { + return deserializeJSON(raw); + } catch (any e) { + return {}; + } +} + +response = { "OK": false }; + +try { + data = readJsonBody(); + childID = val(data.ChildBusinessID ?: 0); + parentID = val(data.ParentBusinessID ?: 0); + + if (childID <= 0) { + response["ERROR"] = "ChildBusinessID required"; + } else { + queryExecute(" + UPDATE Businesses SET BusinessParentBusinessID = :parentId WHERE BusinessID = :childId + ", { + parentId: { value = parentID > 0 ? parentID : javaCast("null", ""), cfsqltype = "cf_sql_integer", null = parentID == 0 }, + childId: childID + }, { datasource = "payfrit" }); + + response["OK"] = true; + response["MESSAGE"] = "Updated BusinessID " & childID & " parent to " & (parentID > 0 ? parentID : "NULL"); + } +} catch (any e) { + response["ERROR"] = e.message; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/beacons/getBusinessFromBeacon.cfm b/api/beacons/getBusinessFromBeacon.cfm index a5a14ee..4ba78db 100644 --- a/api/beacons/getBusinessFromBeacon.cfm +++ b/api/beacons/getBusinessFromBeacon.cfm @@ -46,7 +46,6 @@ beaconId = int(data.BeaconID); - SELECT lt.BusinessID, @@ -62,17 +61,63 @@ beaconId = int(data.BeaconID); ORDER BY biz.BusinessParentBusinessID IS NULL DESC, biz.BusinessName ASC + + + + + + SELECT COUNT(*) as cnt FROM Businesses + WHERE BusinessParentBusinessID = + + + + + + + - - - + + + + + + SELECT BusinessName, BusinessHeaderImageExtension + FROM Businesses + WHERE BusinessID = + + + SELECT + BusinessID, + BusinessName, + BusinessParentBusinessID, + BusinessHeaderImageExtension + FROM Businesses + WHERE BusinessParentBusinessID = + ORDER BY BusinessName ASC + + + + + + + + + + + + + + + #serializeJSON(response)# diff --git a/api/beacons/lookup.cfm b/api/beacons/lookup.cfm new file mode 100644 index 0000000..515cc98 --- /dev/null +++ b/api/beacons/lookup.cfm @@ -0,0 +1,113 @@ + + + + + +/** + * Lookup beacons by UUID + * + * POST: { + * UUIDs: array of UUID strings (without dashes, uppercase) + * } + * + * Returns: { + * OK: true, + * BEACONS: [ + * { + * UUID: "...", + * BeaconID: int, + * BeaconName: string, + * BusinessID: int, + * BusinessName: string, + * ServicePointID: int, + * ServicePointName: string, + * ParentBusinessID: int (if applicable), + * ParentBusinessName: string (if applicable), + * HasChildren: boolean + * } + * ] + * } + */ + +response = { "OK": false }; + +try { + requestData = deserializeJSON(toString(getHttpRequestData().content)); + + uuids = requestData.UUIDs ?: []; + + if (!isArray(uuids) || arrayLen(uuids) == 0) { + response["OK"] = true; + response["BEACONS"] = []; + writeOutput(serializeJSON(response)); + abort; + } + + // Clean and normalize UUIDs (remove dashes, uppercase) + cleanUUIDs = []; + for (uuid in uuids) { + cleanUUID = uCase(reReplace(uuid, "-", "", "all")); + if (len(cleanUUID) == 32) { + arrayAppend(cleanUUIDs, cleanUUID); + } + } + + if (arrayLen(cleanUUIDs) == 0) { + response["OK"] = true; + response["BEACONS"] = []; + writeOutput(serializeJSON(response)); + abort; + } + + // Query for matching beacons with business info + // Beacons link to ServicePoints via lt_Beacon_Businesses_ServicePoints + qBeacons = queryExecute(" + SELECT + b.BeaconID, + b.BeaconName, + b.BeaconUUID, + COALESCE(link.ServicePointID, 0) AS ServicePointID, + COALESCE(sp.ServicePointName, '') AS ServicePointName, + COALESCE(link.BusinessID, b.BeaconBusinessID) AS BusinessID, + biz.BusinessName, + biz.BusinessParentBusinessID, + parent.BusinessName AS ParentBusinessName, + (SELECT COUNT(*) FROM Businesses WHERE BusinessParentBusinessID = biz.BusinessID) AS ChildCount + FROM Beacons b + LEFT JOIN lt_Beacon_Businesses_ServicePoints link ON b.BeaconID = link.BeaconID + LEFT JOIN ServicePoints sp ON link.ServicePointID = sp.ServicePointID + INNER JOIN Businesses biz ON COALESCE(link.BusinessID, b.BeaconBusinessID) = biz.BusinessID + LEFT JOIN Businesses parent ON biz.BusinessParentBusinessID = parent.BusinessID + WHERE b.BeaconUUID IN (:uuids) + AND b.BeaconIsActive = 1 + AND biz.BusinessIsDemo = 0 + AND biz.BusinessIsPrivate = 0 + ", { + uuids: { value: arrayToList(cleanUUIDs), cfsqltype: "cf_sql_varchar", list: true } + }, { datasource: "payfrit" }); + + beacons = []; + for (row in qBeacons) { + arrayAppend(beacons, { + "UUID": row.BeaconUUID, + "BeaconID": row.BeaconID, + "BeaconName": row.BeaconName, + "BusinessID": row.BusinessID, + "BusinessName": row.BusinessName, + "ServicePointID": row.ServicePointID, + "ServicePointName": row.ServicePointName, + "ParentBusinessID": val(row.BusinessParentBusinessID), + "ParentBusinessName": row.ParentBusinessName ?: "", + "HasChildren": row.ChildCount > 0 + }); + } + + response["OK"] = true; + response["BEACONS"] = beacons; + +} catch (any e) { + response["ERROR"] = e.message; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/businesses/getChildren.cfm b/api/businesses/getChildren.cfm new file mode 100644 index 0000000..cbf0b8a --- /dev/null +++ b/api/businesses/getChildren.cfm @@ -0,0 +1,76 @@ + + + + + + + +function apiAbort(payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + raw = toString(getHttpRequestData().content); + if (isNull(raw) || len(trim(raw)) EQ 0) return {}; + try { + parsed = deserializeJSON(raw); + } catch(any e) { + apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" }); + } + if (!isStruct(parsed)) return {}; + return parsed; +} + +// Support GET param or POST body +parentBusinessId = 0; +if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) { + parentBusinessId = int(url.BusinessID); +} else { + data = readJsonBody(); + if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + parentBusinessId = int(data.BusinessID); + } +} + +if (parentBusinessId LTE 0) { + apiAbort({ OK=false, ERROR="missing_business_id", MESSAGE="BusinessID is required" }); +} + +try { + q = queryExecute( + " + SELECT + BusinessID, + BusinessName + FROM Businesses + WHERE BusinessParentBusinessID = :parentId + ORDER BY BusinessName + ", + { parentId = { value = parentBusinessId, cfsqltype = "cf_sql_integer" } }, + { datasource = "payfrit" } + ); + + rows = []; + for (i = 1; i <= q.recordCount; i++) { + arrayAppend(rows, { + "BusinessID": q.BusinessID[i], + "BusinessName": q.BusinessName[i] + }); + } + + writeOutput(serializeJSON({ + "OK": true, + "ERROR": "", + "BUSINESSES": rows + })); + abort; + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "DETAIL": e.message + }); +} + diff --git a/api/businesses/list.cfm b/api/businesses/list.cfm index ce869c3..e3f263e 100644 --- a/api/businesses/list.cfm +++ b/api/businesses/list.cfm @@ -31,7 +31,10 @@ function haversineDistance(lat1, lng1, lat2, lng2) { var a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * 3.14159265359 / 180) * cos(lat2 * 3.14159265359 / 180) * sin(dLng/2) * sin(dLng/2); - var c = 2 * atn(sqr(a) / sqr(1-a)); + // Clamp a to avoid NaN from sqrt of negative or division by zero + if (a < 0) a = 0; + if (a > 1) a = 1; + var c = 2 * asin(sqr(a)); return R * c; } @@ -41,7 +44,7 @@ try { userLng = structKeyExists(data, "lng") ? val(data.lng) : 0; hasUserLocation = (userLat != 0 AND userLng != 0); - // Get businesses with their address coordinates (exclude demo and hidden) + // Get businesses with their address coordinates (exclude demo and private) q = queryExecute( " SELECT @@ -54,7 +57,7 @@ try { FROM Businesses b LEFT JOIN Addresses a ON b.BusinessAddressID = a.AddressID WHERE (b.BusinessIsDemo = 0 OR b.BusinessIsDemo IS NULL) - AND (b.BusinessIsHidden = 0 OR b.BusinessIsHidden IS NULL) + AND (b.BusinessIsPrivate = 0 OR b.BusinessIsPrivate IS NULL) ORDER BY b.BusinessName ", [], diff --git a/api/chat/getActiveChat.cfm b/api/chat/getActiveChat.cfm index 7b52843..23e0c9c 100644 --- a/api/chat/getActiveChat.cfm +++ b/api/chat/getActiveChat.cfm @@ -3,8 +3,8 @@ -// Check for an active (uncompleted) chat task at a business -// Input: BusinessID, ServicePointID (optional), UserID (optional) +// Check for an active (uncompleted) chat task at a service point +// Input: BusinessID, ServicePointID // Output: { OK: true, HAS_ACTIVE_CHAT: true/false, TASK_ID: ... } function apiAbort(required struct payload) { @@ -27,31 +27,39 @@ try { data = readJsonBody(); businessID = val(structKeyExists(data, "BusinessID") ? data.BusinessID : 0); servicePointID = val(structKeyExists(data, "ServicePointID") ? data.ServicePointID : 0); - userID = val(structKeyExists(data, "UserID") ? data.UserID : 0); if (businessID == 0) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); } - // Look for any active chat task at this business (TaskTypeID = 2, not completed) - // Priority order: - // 1. Chats that are claimed (worker is responding) - // 2. Chats that have messages (ongoing conversation) - // 3. Most recently created chat + if (servicePointID == 0) { + // No service point - can't find specific chat + apiAbort({ + "OK": true, + "HAS_ACTIVE_CHAT": false, + "TASK_ID": 0, + "TASK_TITLE": "" + }); + } + + // Look for any active chat task at this service point + // TaskTypeID = 2 (Chat), not completed, at this service point qChat = queryExecute(" - SELECT t.TaskID, t.TaskTitle, t.TaskSourceID, - (SELECT COUNT(*) FROM ChatMessages m WHERE m.TaskID = t.TaskID) as MessageCount + SELECT t.TaskID, t.TaskTitle, + (SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.TaskID) as LastMessageTime FROM Tasks t WHERE t.TaskBusinessID = :businessID AND t.TaskTypeID = 2 AND t.TaskCompletedOn IS NULL + AND t.TaskSourceType = 'servicepoint' + AND t.TaskSourceID = :servicePointID ORDER BY CASE WHEN t.TaskClaimedByUserID > 0 THEN 0 ELSE 1 END, - (SELECT COUNT(*) FROM ChatMessages m WHERE m.TaskID = t.TaskID) DESC, t.TaskAddedOn DESC LIMIT 1 ", { - businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, + servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (qChat.recordCount > 0) { diff --git a/api/chat/sendMessage.cfm b/api/chat/sendMessage.cfm index 8f6030d..db3acca 100644 --- a/api/chat/sendMessage.cfm +++ b/api/chat/sendMessage.cfm @@ -52,15 +52,20 @@ try { senderType = "customer"; } - // Verify task exists + // Verify task exists and is still open taskQuery = queryExecute(" - SELECT TaskID, TaskClaimedByUserID FROM Tasks WHERE TaskID = :taskID + SELECT TaskID, TaskClaimedByUserID, TaskCompletedOn FROM Tasks WHERE TaskID = :taskID ", { taskID: { value: taskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (taskQuery.recordCount == 0) { apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found" }); } + // Check if chat has been closed + if (len(trim(taskQuery.TaskCompletedOn)) > 0) { + apiAbort({ "OK": false, "ERROR": "chat_closed", "MESSAGE": "This chat has ended" }); + } + // Insert message queryExecute(" INSERT INTO ChatMessages (TaskID, SenderUserID, SenderType, MessageText) diff --git a/api/menu/getForBuilder.cfm b/api/menu/getForBuilder.cfm index d39f16a..721e877 100644 --- a/api/menu/getForBuilder.cfm +++ b/api/menu/getForBuilder.cfm @@ -57,6 +57,35 @@ try { abort; } + // Check for MenuID filter (optional - if provided, only return categories for that menu) + menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0; + + // Get all menus for this business + allMenus = []; + try { + qMenus = queryExecute(" + SELECT MenuID, MenuName, MenuDescription, MenuDaysActive, + MenuStartTime, MenuEndTime, MenuSortOrder + FROM Menus + WHERE MenuBusinessID = :businessID AND MenuIsActive = 1 + ORDER BY MenuSortOrder, MenuName + ", { businessID: businessID }, { datasource: "payfrit" }); + + for (m = 1; m <= qMenus.recordCount; m++) { + arrayAppend(allMenus, { + "MenuID": qMenus.MenuID[m], + "MenuName": qMenus.MenuName[m], + "MenuDescription": isNull(qMenus.MenuDescription[m]) ? "" : qMenus.MenuDescription[m], + "MenuDaysActive": qMenus.MenuDaysActive[m], + "MenuStartTime": isNull(qMenus.MenuStartTime[m]) ? "" : timeFormat(qMenus.MenuStartTime[m], "HH:mm"), + "MenuEndTime": isNull(qMenus.MenuEndTime[m]) ? "" : timeFormat(qMenus.MenuEndTime[m], "HH:mm"), + "MenuSortOrder": qMenus.MenuSortOrder[m] + }); + } + } catch (any e) { + // Menus table might not exist yet + } + // Check if Categories table has data for this business hasCategoriesData = false; try { @@ -70,15 +99,24 @@ try { if (hasCategoriesData) { // OLD SCHEMA: Use Categories table for categories + // Build menu filter clause + menuFilter = ""; + menuParams = { businessID: businessID }; + if (menuID > 0) { + menuFilter = " AND CategoryMenuID = :menuID"; + menuParams["menuID"] = menuID; + } + qCategories = queryExecute(" SELECT CategoryID, CategoryName, - CategorySortOrder as ItemSortOrder + CategorySortOrder as ItemSortOrder, + CategoryMenuID FROM Categories - WHERE CategoryBusinessID = :businessID + WHERE CategoryBusinessID = :businessID #menuFilter# ORDER BY CategorySortOrder, CategoryName - ", { businessID: businessID }, { datasource: "payfrit" }); + ", menuParams, { datasource: "payfrit" }); // Get menu items - items that belong to categories (not modifiers) qItems = queryExecute(" @@ -328,14 +366,25 @@ try { catID = qCategories.CategoryID[i]; catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : []; - arrayAppend(categories, { + catStruct = { "id": "cat_" & qCategories.CategoryID[i], "dbId": qCategories.CategoryID[i], "name": qCategories.CategoryName[i], "description": "", "sortOrder": catIndex, "items": catItems - }); + }; + + // Include MenuID if available (legacy schema with Categories table) + if (hasCategoriesData) { + try { + catStruct["menuId"] = isNull(qCategories.CategoryMenuID[i]) ? 0 : val(qCategories.CategoryMenuID[i]); + } catch (any e) { + catStruct["menuId"] = 0; + } + } + + arrayAppend(categories, catStruct); catIndex++; } @@ -360,10 +409,13 @@ try { response["OK"] = true; response["MENU"] = { "categories": categories }; + response["MENUS"] = allMenus; + response["SELECTED_MENU_ID"] = menuID; response["TEMPLATES"] = templateLibrary; response["BRANDCOLOR"] = brandColor; response["CATEGORY_COUNT"] = arrayLen(categories); response["TEMPLATE_COUNT"] = arrayLen(templateLibrary); + response["MENU_COUNT"] = arrayLen(allMenus); response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified"; totalItems = 0; diff --git a/api/menu/items.cfm b/api/menu/items.cfm index 0b4da53..7ef995f 100644 --- a/api/menu/items.cfm +++ b/api/menu/items.cfm @@ -32,10 +32,20 @@ + + + + + + + + + + @@ -70,24 +80,89 @@ - + + + + 0 + AND ( + (MenuStartTime IS NULL OR MenuEndTime IS NULL) + OR (TIME(:currentTime) >= MenuStartTime AND TIME(:currentTime) <= MenuEndTime) + ) + ", + { + bizId: { value = BusinessID, cfsqltype = "cf_sql_integer" }, + dayBit: { value = 2 ^ (currentDayID - 1), cfsqltype = "cf_sql_integer" }, + currentTime: { value = currentTime, cfsqltype = "cf_sql_varchar" } + }, + { datasource = "payfrit" } + )> + + + + + + + + 0 + ) + AND ( + CategoryScheduleStart IS NULL + OR CategoryScheduleEnd IS NULL + OR ( + TIME(:currentTime) >= CategoryScheduleStart + AND TIME(:currentTime) <= CategoryScheduleEnd + ) + ) + AND ( + CategoryScheduleDays IS NULL + OR CategoryScheduleDays = '' + OR FIND_IN_SET(:currentDay, CategoryScheduleDays) > 0 + ) + AND ( + CategoryMenuID IS NULL + OR CategoryMenuID = 0 + #len(activeMenuIds) ? "OR CategoryMenuID IN (#activeMenuIds#)" : ""# + ) ORDER BY CategorySortOrder ", - [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], + { + bizId: { value = BusinessID, cfsqltype = "cf_sql_integer" }, + orderTypeId: { value = OrderTypeID, cfsqltype = "cf_sql_integer" }, + currentTime: { value = currentTime, cfsqltype = "cf_sql_varchar" }, + currentDay: { value = currentDayID, cfsqltype = "cf_sql_integer" } + }, { datasource = "payfrit" } )> + + + + + diff --git a/api/menu/listCategories.cfm b/api/menu/listCategories.cfm new file mode 100644 index 0000000..10e5738 --- /dev/null +++ b/api/menu/listCategories.cfm @@ -0,0 +1,89 @@ + + + + + + +/** + * List Categories for a Business + * + * POST body: + * { + * "BusinessID": 37 + * } + * + * Returns all categories with their schedule settings. + */ + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(raw)) == 0) return {}; + try { + var data = deserializeJSON(raw); + return isStruct(data) ? data : {}; + } catch (any e) { + return {}; + } +} + +response = { "OK": false }; + +try { + data = readJsonBody(); + + BusinessID = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 0; + + if (BusinessID <= 0) { + response["ERROR"] = "missing_businessid"; + response["MESSAGE"] = "BusinessID is required"; + writeOutput(serializeJSON(response)); + abort; + } + + q = queryExecute(" + SELECT + CategoryID, + CategoryBusinessID, + CategoryParentCategoryID, + CategoryName, + CategoryImageExtension, + CategoryOrderTypes, + CategoryScheduleStart, + CategoryScheduleEnd, + CategoryScheduleDays, + CategorySortOrder, + CategoryAddedOn + FROM Categories + WHERE CategoryBusinessID = :bizId + ORDER BY CategorySortOrder, CategoryName + ", { bizId: BusinessID }, { datasource = "payfrit" }); + + categories = []; + for (row in q) { + arrayAppend(categories, { + "CategoryID": row.CategoryID, + "CategoryBusinessID": row.CategoryBusinessID, + "CategoryParentCategoryID": row.CategoryParentCategoryID, + "CategoryName": row.CategoryName, + "CategoryImageExtension": len(trim(row.CategoryImageExtension)) ? row.CategoryImageExtension : "", + "CategoryOrderTypes": row.CategoryOrderTypes, + "CategoryScheduleStart": isNull(row.CategoryScheduleStart) ? "" : timeFormat(row.CategoryScheduleStart, "HH:mm:ss"), + "CategoryScheduleEnd": isNull(row.CategoryScheduleEnd) ? "" : timeFormat(row.CategoryScheduleEnd, "HH:mm:ss"), + "CategoryScheduleDays": isNull(row.CategoryScheduleDays) ? "" : row.CategoryScheduleDays, + "CategorySortOrder": row.CategorySortOrder, + "CategoryAddedOn": dateTimeFormat(row.CategoryAddedOn, "yyyy-mm-dd HH:nn:ss") + }); + } + + response["OK"] = true; + response["Categories"] = categories; + response["COUNT"] = arrayLen(categories); + +} catch (any e) { + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/menu/menus.cfm b/api/menu/menus.cfm new file mode 100644 index 0000000..68cf292 --- /dev/null +++ b/api/menu/menus.cfm @@ -0,0 +1,248 @@ + + + + + + +/** + * Menu CRUD API + * + * GET: List all menus for a business + * POST: Create or update a menu + * DELETE: Soft-delete a menu + * + * Input: BusinessID, and optionally Menu data for POST + */ + +response = { "OK": false }; + +function apiAbort(payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +try { + requestBody = toString(getHttpRequestData().content); + requestData = {}; + if (len(requestBody)) { + requestData = deserializeJSON(requestBody); + } + + businessID = 0; + if (structKeyExists(requestData, "BusinessID")) { + businessID = val(requestData.BusinessID); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_business_id", "MESSAGE": "BusinessID is required" }); + } + + method = cgi.REQUEST_METHOD; + action = structKeyExists(requestData, "action") ? lCase(requestData.action) : "list"; + + // Handle different actions + switch (action) { + case "list": + // Get all active menus for this business + qMenus = queryExecute(" + SELECT + MenuID, + MenuName, + MenuDescription, + MenuDaysActive, + MenuStartTime, + MenuEndTime, + MenuSortOrder, + MenuIsActive + FROM Menus + WHERE MenuBusinessID = :businessID + AND MenuIsActive = 1 + ORDER BY MenuSortOrder, MenuName + ", { businessID: businessID }, { datasource: "payfrit" }); + + menus = []; + for (i = 1; i <= qMenus.recordCount; i++) { + // Count categories in this menu + qCatCount = queryExecute(" + SELECT COUNT(*) as cnt FROM Categories + WHERE CategoryBusinessID = :businessID + AND CategoryMenuID = :menuID + ", { businessID: businessID, menuID: qMenus.MenuID[i] }, { datasource: "payfrit" }); + + arrayAppend(menus, { + "MenuID": qMenus.MenuID[i], + "MenuName": qMenus.MenuName[i], + "MenuDescription": isNull(qMenus.MenuDescription[i]) ? "" : qMenus.MenuDescription[i], + "MenuDaysActive": qMenus.MenuDaysActive[i], + "MenuStartTime": isNull(qMenus.MenuStartTime[i]) ? "" : timeFormat(qMenus.MenuStartTime[i], "HH:mm"), + "MenuEndTime": isNull(qMenus.MenuEndTime[i]) ? "" : timeFormat(qMenus.MenuEndTime[i], "HH:mm"), + "MenuSortOrder": qMenus.MenuSortOrder[i], + "CategoryCount": qCatCount.cnt + }); + } + + response = { + "OK": true, + "MENUS": menus, + "COUNT": arrayLen(menus) + }; + break; + + case "get": + // Get a single menu by ID + menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0; + if (menuID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_menu_id", "MESSAGE": "MenuID is required" }); + } + + qMenu = queryExecute(" + SELECT * FROM Menus + WHERE MenuID = :menuID AND MenuBusinessID = :businessID + ", { menuID: menuID, businessID: businessID }, { datasource: "payfrit" }); + + if (qMenu.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "menu_not_found", "MESSAGE": "Menu not found" }); + } + + response = { + "OK": true, + "MENU": { + "MenuID": qMenu.MenuID, + "MenuName": qMenu.MenuName, + "MenuDescription": isNull(qMenu.MenuDescription) ? "" : qMenu.MenuDescription, + "MenuDaysActive": qMenu.MenuDaysActive, + "MenuStartTime": isNull(qMenu.MenuStartTime) ? "" : timeFormat(qMenu.MenuStartTime, "HH:mm"), + "MenuEndTime": isNull(qMenu.MenuEndTime) ? "" : timeFormat(qMenu.MenuEndTime, "HH:mm"), + "MenuSortOrder": qMenu.MenuSortOrder, + "MenuIsActive": qMenu.MenuIsActive + } + }; + break; + + case "save": + // Create or update a menu + menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0; + menuName = structKeyExists(requestData, "MenuName") ? trim(requestData.MenuName) : ""; + menuDescription = structKeyExists(requestData, "MenuDescription") ? trim(requestData.MenuDescription) : ""; + menuDaysActive = structKeyExists(requestData, "MenuDaysActive") ? val(requestData.MenuDaysActive) : 127; + menuStartTime = structKeyExists(requestData, "MenuStartTime") && len(trim(requestData.MenuStartTime)) ? trim(requestData.MenuStartTime) : javaCast("null", ""); + menuEndTime = structKeyExists(requestData, "MenuEndTime") && len(trim(requestData.MenuEndTime)) ? trim(requestData.MenuEndTime) : javaCast("null", ""); + menuSortOrder = structKeyExists(requestData, "MenuSortOrder") ? val(requestData.MenuSortOrder) : 0; + + if (len(menuName) == 0) { + apiAbort({ "OK": false, "ERROR": "missing_menu_name", "MESSAGE": "Menu name is required" }); + } + + if (menuID > 0) { + // Update existing menu + queryExecute(" + UPDATE Menus SET + MenuName = :menuName, + MenuDescription = :menuDescription, + MenuDaysActive = :menuDaysActive, + MenuStartTime = :menuStartTime, + MenuEndTime = :menuEndTime, + MenuSortOrder = :menuSortOrder + WHERE MenuID = :menuID AND MenuBusinessID = :businessID + ", { + menuID: menuID, + businessID: businessID, + menuName: menuName, + menuDescription: menuDescription, + menuDaysActive: menuDaysActive, + menuStartTime: menuStartTime, + menuEndTime: menuEndTime, + menuSortOrder: menuSortOrder + }, { datasource: "payfrit" }); + + response = { "OK": true, "MenuID": menuID, "ACTION": "updated" }; + } else { + // Create new menu + queryExecute(" + INSERT INTO Menus ( + MenuBusinessID, MenuName, MenuDescription, + MenuDaysActive, MenuStartTime, MenuEndTime, + MenuSortOrder, MenuIsActive, MenuAddedOn + ) VALUES ( + :businessID, :menuName, :menuDescription, + :menuDaysActive, :menuStartTime, :menuEndTime, + :menuSortOrder, 1, NOW() + ) + ", { + businessID: businessID, + menuName: menuName, + menuDescription: menuDescription, + menuDaysActive: menuDaysActive, + menuStartTime: menuStartTime, + menuEndTime: menuEndTime, + menuSortOrder: menuSortOrder + }, { datasource: "payfrit" }); + + result = queryExecute("SELECT LAST_INSERT_ID() as newID", {}, { datasource: "payfrit" }); + response = { "OK": true, "MenuID": result.newID, "ACTION": "created" }; + } + break; + + case "delete": + // Soft-delete a menu + menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0; + if (menuID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_menu_id", "MESSAGE": "MenuID is required" }); + } + + // Check if menu has categories + qCatCheck = queryExecute(" + SELECT COUNT(*) as cnt FROM Categories + WHERE CategoryMenuID = :menuID + ", { menuID: menuID }, { datasource: "payfrit" }); + + if (qCatCheck.cnt > 0) { + apiAbort({ + "OK": false, + "ERROR": "menu_has_categories", + "MESSAGE": "Cannot delete menu with categories. Move or delete categories first.", + "CATEGORY_COUNT": qCatCheck.cnt + }); + } + + queryExecute(" + UPDATE Menus SET MenuIsActive = 0 + WHERE MenuID = :menuID AND MenuBusinessID = :businessID + ", { menuID: menuID, businessID: businessID }, { datasource: "payfrit" }); + + response = { "OK": true, "MenuID": menuID, "ACTION": "deleted" }; + break; + + case "reorder": + // Reorder menus + menuOrder = structKeyExists(requestData, "MenuOrder") ? requestData.MenuOrder : []; + if (!isArray(menuOrder) || arrayLen(menuOrder) == 0) { + apiAbort({ "OK": false, "ERROR": "missing_menu_order", "MESSAGE": "MenuOrder array is required" }); + } + + for (i = 1; i <= arrayLen(menuOrder); i++) { + queryExecute(" + UPDATE Menus SET MenuSortOrder = :sortOrder + WHERE MenuID = :menuID AND MenuBusinessID = :businessID + ", { + menuID: val(menuOrder[i]), + businessID: businessID, + sortOrder: i - 1 + }, { datasource: "payfrit" }); + } + + response = { "OK": true, "ACTION": "reordered" }; + break; + + default: + apiAbort({ "OK": false, "ERROR": "invalid_action", "MESSAGE": "Unknown action: " & action }); + } + +} catch (any e) { + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; + response["DETAIL"] = e.detail ?: ""; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/menu/saveCategory.cfm b/api/menu/saveCategory.cfm new file mode 100644 index 0000000..5d8ee1d --- /dev/null +++ b/api/menu/saveCategory.cfm @@ -0,0 +1,111 @@ + + + + + + +/** + * Save/Update Category + * + * POST body: + * { + * "CategoryID": 123, // Required for update + * "CategoryBusinessID": 37, // Required for insert + * "CategoryName": "Breakfast", + * "CategorySortOrder": 1, + * "CategoryOrderTypes": "1,2,3", // 1=Dine-In, 2=Takeaway, 3=Delivery + * "CategoryScheduleStart": "06:00:00", // Optional, null = always available + * "CategoryScheduleEnd": "11:00:00", // Optional + * "CategoryScheduleDays": "2,3,4,5,6" // Optional, 1=Sun..7=Sat, null = all days + * } + */ + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw) || len(trim(raw)) == 0) return {}; + try { + var data = deserializeJSON(raw); + return isStruct(data) ? data : {}; + } catch (any e) { + return {}; + } +} + +response = { "OK": false }; + +try { + data = readJsonBody(); + + CategoryID = structKeyExists(data, "CategoryID") ? val(data.CategoryID) : 0; + CategoryBusinessID = structKeyExists(data, "CategoryBusinessID") ? val(data.CategoryBusinessID) : 0; + CategoryName = structKeyExists(data, "CategoryName") ? left(trim(data.CategoryName), 30) : ""; + CategorySortOrder = structKeyExists(data, "CategorySortOrder") ? val(data.CategorySortOrder) : 0; + CategoryOrderTypes = structKeyExists(data, "CategoryOrderTypes") ? trim(data.CategoryOrderTypes) : "1,2,3"; + CategoryScheduleStart = structKeyExists(data, "CategoryScheduleStart") && len(trim(data.CategoryScheduleStart)) + ? trim(data.CategoryScheduleStart) : javaCast("null", ""); + CategoryScheduleEnd = structKeyExists(data, "CategoryScheduleEnd") && len(trim(data.CategoryScheduleEnd)) + ? trim(data.CategoryScheduleEnd) : javaCast("null", ""); + CategoryScheduleDays = structKeyExists(data, "CategoryScheduleDays") && len(trim(data.CategoryScheduleDays)) + ? trim(data.CategoryScheduleDays) : javaCast("null", ""); + + if (CategoryID > 0) { + // Update existing category + queryExecute(" + UPDATE Categories SET + CategoryName = :name, + CategorySortOrder = :sortOrder, + CategoryOrderTypes = :orderTypes, + CategoryScheduleStart = :schedStart, + CategoryScheduleEnd = :schedEnd, + CategoryScheduleDays = :schedDays + WHERE CategoryID = :catId + ", { + catId: CategoryID, + name: CategoryName, + sortOrder: CategorySortOrder, + orderTypes: CategoryOrderTypes, + schedStart: { value = CategoryScheduleStart, cfsqltype = "cf_sql_time", null = isNull(CategoryScheduleStart) }, + schedEnd: { value = CategoryScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(CategoryScheduleEnd) }, + schedDays: { value = CategoryScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(CategoryScheduleDays) } + }, { datasource = "payfrit" }); + + response["OK"] = true; + response["CategoryID"] = CategoryID; + response["MESSAGE"] = "Category updated"; + + } else if (CategoryBusinessID > 0 && len(CategoryName)) { + // Insert new category + queryExecute(" + INSERT INTO Categories + (CategoryBusinessID, CategoryName, CategorySortOrder, CategoryOrderTypes, + CategoryScheduleStart, CategoryScheduleEnd, CategoryScheduleDays, CategoryAddedOn) + VALUES + (:bizId, :name, :sortOrder, :orderTypes, :schedStart, :schedEnd, :schedDays, NOW()) + ", { + bizId: CategoryBusinessID, + name: CategoryName, + sortOrder: CategorySortOrder, + orderTypes: CategoryOrderTypes, + schedStart: { value = CategoryScheduleStart, cfsqltype = "cf_sql_time", null = isNull(CategoryScheduleStart) }, + schedEnd: { value = CategoryScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(CategoryScheduleEnd) }, + schedDays: { value = CategoryScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(CategoryScheduleDays) } + }, { datasource = "payfrit" }); + + qNew = queryExecute("SELECT LAST_INSERT_ID() as newId", {}, { datasource = "payfrit" }); + response["OK"] = true; + response["CategoryID"] = qNew.newId; + response["MESSAGE"] = "Category created"; + + } else { + response["ERROR"] = "invalid_params"; + response["MESSAGE"] = "CategoryID required for update, or CategoryBusinessID and CategoryName for insert"; + } + +} catch (any e) { + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; + response["DETAIL"] = e.detail; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/menu/saveFromBuilder.cfm b/api/menu/saveFromBuilder.cfm index a042a52..64c29e1 100644 --- a/api/menu/saveFromBuilder.cfm +++ b/api/menu/saveFromBuilder.cfm @@ -151,24 +151,31 @@ try { categoryID = result.newID; } } else { + // Get menu ID from category if provided + categoryMenuId = structKeyExists(cat, "menuId") ? val(cat.menuId) : 0; + categoryMenuIdParam = categoryMenuId > 0 ? categoryMenuId : javaCast("null", ""); + if (categoryDbId > 0) { categoryID = categoryDbId; queryExecute(" UPDATE Categories SET CategoryName = :name, - CategorySortOrder = :sortOrder + CategorySortOrder = :sortOrder, + CategoryMenuID = :menuId WHERE CategoryID = :categoryID ", { categoryID: categoryID, name: cat.name, - sortOrder: catSortOrder + sortOrder: catSortOrder, + menuId: categoryMenuIdParam }); } else { queryExecute(" - INSERT INTO Categories (CategoryBusinessID, CategoryName, CategorySortOrder, CategoryAddedOn) - VALUES (:businessID, :name, :sortOrder, NOW()) + INSERT INTO Categories (CategoryBusinessID, CategoryMenuID, CategoryName, CategorySortOrder, CategoryAddedOn) + VALUES (:businessID, :menuId, :name, :sortOrder, NOW()) ", { businessID: businessID, + menuId: categoryMenuIdParam, name: cat.name, sortOrder: catSortOrder }); diff --git a/api/orders/abandonOrder.cfm b/api/orders/abandonOrder.cfm index 1bc9042..cf9794d 100644 --- a/api/orders/abandonOrder.cfm +++ b/api/orders/abandonOrder.cfm @@ -50,17 +50,16 @@ - - + - + diff --git a/api/setup/saveWizard.cfm b/api/setup/saveWizard.cfm index 8b2b662..33e063f 100644 --- a/api/setup/saveWizard.cfm +++ b/api/setup/saveWizard.cfm @@ -83,7 +83,7 @@ try { ", { line1: len(addressLine1) ? addressLine1 : "Address pending", city: len(city) ? city : "", - stateID: stateID, + stateID: { value = stateID > 0 ? stateID : javaCast("null", ""), cfsqltype = "cf_sql_integer", null = stateID == 0 }, zip: len(zip) ? zip : "", userID: userId, typeID: 2 @@ -118,6 +118,32 @@ try { }, { datasource: "payfrit" }); response.steps.append("Linked address to business"); + // Create default task types for the business + // 1. Call Server (notifications icon, purple) + // 2. Chat With Staff (chat icon, blue) + // 3. Pay With Cash (payments icon, green) + defaultTaskTypes = [ + { name: "Call Server", icon: "notifications", color: "##9C27B0", description: "Request server 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" } + ]; + + for (tt = 1; tt <= arrayLen(defaultTaskTypes); tt++) { + taskType = defaultTaskTypes[tt]; + queryExecute(" + INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID, tt_TaskTypeSortOrder) + 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 3 default task types (Call Server, Chat With Staff, Pay With Cash)"); + // Save business hours from structured schedule if (structKeyExists(biz, "hoursSchedule") && isArray(biz.hoursSchedule)) { hoursSchedule = biz.hoursSchedule; @@ -255,6 +281,83 @@ try { } } + // 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 = queryExecute(" + SELECT MIN(HoursOpenTime) as earliestOpen, MAX(HoursClosingTime) as latestClose + FROM Hours + WHERE HoursBusinessID = :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 = queryExecute(" + SELECT MenuID FROM Menus + WHERE MenuBusinessID = :bizID AND MenuName = :name AND MenuIsActive = 1 + ", { bizID: businessId, name: menuName }, { datasource: "payfrit" }); + + if (qMenu.recordCount > 0) { + menuID = qMenu.MenuID; + // Update existing menu with new time range if provided + if (len(menuStartTime) && len(menuEndTime)) { + queryExecute(" + UPDATE Menus SET MenuStartTime = :startTime, MenuEndTime = :endTime + WHERE MenuID = :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 { + queryExecute(" + INSERT INTO Menus ( + MenuBusinessID, MenuName, MenuDaysActive, MenuStartTime, MenuEndTime, MenuSortOrder, MenuIsActive, MenuAddedOn + ) 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 = queryExecute("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 @@ -270,32 +373,33 @@ try { continue; } - // Check if category exists in Categories table + // Check if category exists in Categories table for this menu qCat = queryExecute(" SELECT CategoryID FROM Categories - WHERE CategoryBusinessID = :bizID AND CategoryName = :name - ", { bizID: businessId, name: catName }, { datasource: "payfrit" }); + WHERE CategoryBusinessID = :bizID AND CategoryName = :name AND CategoryMenuID = :menuID + ", { bizID: businessId, name: catName, menuID: menuID }, { datasource: "payfrit" }); if (qCat.recordCount > 0) { categoryID = qCat.CategoryID; response.steps.append("Category exists: " & catName & " (ID: " & categoryID & ")"); } else { - // Create category in Categories table + // Create category in Categories table with MenuID queryExecute(" INSERT INTO Categories ( - CategoryBusinessID, CategoryName, CategorySortOrder + CategoryBusinessID, CategoryMenuID, CategoryName, CategorySortOrder ) VALUES ( - :bizID, :name, :sortOrder + :bizID, :menuID, :name, :sortOrder ) ", { bizID: businessId, + menuID: menuID, name: catName, sortOrder: catOrder }, { datasource: "payfrit" }); qNewCat = queryExecute("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); categoryID = qNewCat.id; - response.steps.append("Created category: " & catName & " (ID: " & categoryID & ")"); + response.steps.append("Created category: " & catName & " in menu " & menuName & " (ID: " & categoryID & ")"); } categoryMap[catName] = categoryID; diff --git a/api/tasks/callServer.cfm b/api/tasks/callServer.cfm index f8a2fb8..a8764fc 100644 --- a/api/tasks/callServer.cfm +++ b/api/tasks/callServer.cfm @@ -30,6 +30,7 @@ try { orderID = val(structKeyExists(data, "OrderID") ? data.OrderID : 0); message = trim(structKeyExists(data, "Message") ? data.Message : ""); userID = val(structKeyExists(data, "UserID") ? data.UserID : 0); + taskTypeID = val(structKeyExists(data, "TaskTypeID") ? data.TaskTypeID : 0); if (businessID == 0) { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); @@ -105,7 +106,7 @@ try { :businessID, :categoryID, :orderID, - 1, + :taskTypeID, :title, :details, 0, @@ -115,6 +116,7 @@ try { businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, categoryID: { value: categoryID, cfsqltype: "cf_sql_integer" }, orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 }, + taskTypeID: { value: taskTypeID > 0 ? taskTypeID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: taskTypeID == 0 }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, details: { value: taskDetails, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" }); diff --git a/api/tasks/createChat.cfm b/api/tasks/createChat.cfm index ad88e2d..ebf142c 100644 --- a/api/tasks/createChat.cfm +++ b/api/tasks/createChat.cfm @@ -4,7 +4,7 @@ // Customer initiates a chat with staff -// Input: BusinessID, ServicePointID, OrderID (optional), Message (optional initial message) +// Input: BusinessID, ServicePointID, OrderID (optional), UserID (optional), Message (optional initial message) // Output: { OK: true, TaskID: ... } function apiAbort(required struct payload) { @@ -35,41 +35,48 @@ try { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); } - if (servicePointID == 0) { - apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" }); - } + // ServicePointID = 0 is allowed for remote chats (non-dine-in users) + // In that case, we use userID to match existing chats - // Check for existing open chat for this user at this business + // Check for existing open chat at this service point // An open chat is one where TaskTypeID=2 (Chat) and TaskCompletedOn IS NULL - // Also check it's tied to the current order if we have one forceNew = structKeyExists(data, "ForceNew") && data.ForceNew == true; - if (userID > 0 && !forceNew) { + if (!forceNew) { + // Look for any active chat for this business + // Check by: order match, service point match, OR user match (for remote chats) existingChat = queryExecute(" - SELECT t.TaskID, t.TaskAddedOn + SELECT t.TaskID, t.TaskAddedOn, + (SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.TaskID) as LastMessageTime FROM Tasks t - LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID + LEFT JOIN ChatMessages cm2 ON cm2.TaskID = t.TaskID AND cm2.SenderUserID = :userID WHERE t.TaskBusinessID = :businessID AND t.TaskTypeID = 2 AND t.TaskCompletedOn IS NULL AND ( (t.TaskOrderID = :orderID AND :orderID > 0) - OR (t.TaskOrderID IS NULL AND o.OrderUserID = :userID) - OR (t.TaskOrderID IS NULL AND t.TaskSourceID = :servicePointID) + OR (t.TaskSourceType = 'servicepoint' AND t.TaskSourceID = :servicePointID AND :servicePointID > 0) + OR (t.TaskSourceType = 'user' AND t.TaskSourceID = :userID AND :userID > 0) + OR (cm2.SenderUserID = :userID AND :userID > 0) ) ORDER BY t.TaskAddedOn DESC LIMIT 1 ", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" }, - userID: { value: userID, cfsqltype: "cf_sql_integer" }, servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" }, - orderID: { value: orderID, cfsqltype: "cf_sql_integer" } + orderID: { value: orderID, cfsqltype: "cf_sql_integer" }, + userID: { value: userID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (existingChat.recordCount > 0) { - // Check if chat is stale (more than 20 minutes old with no activity) - chatAge = dateDiff("n", existingChat.TaskAddedOn, now()); - if (chatAge > 20) { + // Check if chat is stale (more than 30 minutes since last message, or 30 min since creation if no messages) + lastActivity = existingChat.LastMessageTime; + if (isNull(lastActivity) || !isDate(lastActivity)) { + lastActivity = existingChat.TaskAddedOn; + } + chatAge = dateDiff("n", lastActivity, now()); + + if (chatAge > 30) { // Auto-close stale chat queryExecute(" UPDATE Tasks SET TaskCompletedOn = NOW() @@ -87,12 +94,14 @@ try { } } - // Get service point info (table name) - spQuery = queryExecute(" - SELECT ServicePointName FROM ServicePoints WHERE ServicePointID = :spID - ", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); - - tableName = spQuery.recordCount ? spQuery.ServicePointName : "Table ##" & servicePointID; + // Get service point info (table name) - only if dine-in + tableName = ""; + if (servicePointID > 0) { + spQuery = queryExecute(" + SELECT ServicePointName FROM ServicePoints WHERE ServicePointID = :spID + ", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); + tableName = spQuery.recordCount ? spQuery.ServicePointName : "Table ##" & servicePointID; + } // Get user name if available userName = ""; @@ -105,10 +114,20 @@ try { } } - // Create task title - taskTitle = "Chat - " & tableName; - if (len(userName)) { - taskTitle = "Chat - " & userName & " (" & tableName & ")"; + // Create task title - different format for dine-in vs remote + if (servicePointID > 0) { + // Dine-in: "Chat - UserName (TableName)" or "Chat - TableName" + taskTitle = "Chat - " & tableName; + if (len(userName)) { + taskTitle = "Chat - " & userName & " (" & tableName & ")"; + } + } else { + // Remote: "Chat - UserName (Remote)" or "Remote Chat" + if (len(userName)) { + taskTitle = "Chat - " & userName & " (Remote)"; + } else { + taskTitle = "Remote Chat"; + } } taskDetails = "Customer initiated chat"; @@ -136,6 +155,15 @@ try { categoryID = catQuery.TaskCategoryID; } + // Determine source type and ID based on dine-in vs remote + if (servicePointID > 0) { + sourceType = "servicepoint"; + sourceID = servicePointID; + } else { + sourceType = "user"; + sourceID = userID; + } + // Insert task with TaskTypeID = 2 (Chat) queryExecute(" INSERT INTO Tasks ( @@ -157,8 +185,8 @@ try { :title, :details, 0, - 'servicepoint', - :servicePointID, + :sourceType, + :sourceID, NOW() ) ", { @@ -167,7 +195,8 @@ try { orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 }, title: { value: taskTitle, cfsqltype: "cf_sql_varchar" }, details: { value: taskDetails, cfsqltype: "cf_sql_varchar" }, - servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" } + sourceType: { value: sourceType, cfsqltype: "cf_sql_varchar" }, + sourceID: { value: sourceID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); // Get the new task ID diff --git a/api/tasks/deleteType.cfm b/api/tasks/deleteType.cfm new file mode 100644 index 0000000..69218a7 --- /dev/null +++ b/api/tasks/deleteType.cfm @@ -0,0 +1,86 @@ + + + + + +// Delete a task type for a business +// Input: TaskTypeID (required), BusinessID (required) +// Output: { OK: true } + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get TaskTypeID + taskTypeID = 0; + if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) { + taskTypeID = int(data.TaskTypeID); + } + if (taskTypeID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskTypeID is required" }); + } + + // Get BusinessID + businessID = 0; + if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Verify task type exists and belongs to this business + qCheck = queryExecute(" + SELECT tt_TaskTypeID, tt_TaskTypeBusinessID + FROM tt_TaskTypes + WHERE tt_TaskTypeID = :taskTypeID + ", { + taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task type not found" }); + } + + if (isNull(qCheck.tt_TaskTypeBusinessID) || qCheck.tt_TaskTypeBusinessID != businessID) { + apiAbort({ "OK": false, "ERROR": "not_authorized", "MESSAGE": "Task type does not belong to this business" }); + } + + // Delete the task type + queryExecute(" + DELETE FROM tt_TaskTypes + WHERE tt_TaskTypeID = :taskTypeID + AND tt_TaskTypeBusinessID = :businessID + ", { + taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "MESSAGE": "Task type deleted" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/tasks/listAllTypes.cfm b/api/tasks/listAllTypes.cfm new file mode 100644 index 0000000..5245afa --- /dev/null +++ b/api/tasks/listAllTypes.cfm @@ -0,0 +1,85 @@ + + + + + +// Returns all task types for a business (for portal management) +// Input: BusinessID (required) +// Output: { OK: true, TASK_TYPES: [...] } + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID from body, header, or URL + businessID = 0; + httpHeaders = getHttpRequestData().headers; + + if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) { + businessID = int(httpHeaders["X-Business-ID"]); + } else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) { + businessID = int(url.BusinessID); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get task types for this business + q = queryExecute(" + SELECT + tt_TaskTypeID as TaskTypeID, + tt_TaskTypeName as TaskTypeName, + tt_TaskTypeDescription as TaskTypeDescription, + tt_TaskTypeIcon as TaskTypeIcon, + tt_TaskTypeColor as TaskTypeColor, + tt_TaskTypeSortOrder as SortOrder + FROM tt_TaskTypes + WHERE tt_TaskTypeBusinessID = :businessID + ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + taskTypes = []; + for (row in q) { + arrayAppend(taskTypes, { + "TaskTypeID": row.TaskTypeID, + "TaskTypeName": row.TaskTypeName, + "TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription, + "TaskTypeIcon": isNull(row.TaskTypeIcon) ? "notifications" : row.TaskTypeIcon, + "TaskTypeColor": isNull(row.TaskTypeColor) ? "##9C27B0" : row.TaskTypeColor + }); + } + + apiAbort({ + "OK": true, + "TASK_TYPES": taskTypes, + "COUNT": arrayLen(taskTypes) + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/tasks/listMine.cfm b/api/tasks/listMine.cfm index bffe74e..03d0176 100644 --- a/api/tasks/listMine.cfm +++ b/api/tasks/listMine.cfm @@ -71,15 +71,21 @@ t.TaskCategoryID, t.TaskOrderID, t.TaskTypeID, + t.TaskTitle, + t.TaskDetails, t.TaskAddedOn, t.TaskClaimedByUserID, t.TaskClaimedOn, t.TaskCompletedOn, tc.TaskCategoryName, tc.TaskCategoryColor, + tt.tt_TaskTypeName AS TaskTypeName, + tt.tt_TaskTypeIcon AS TaskTypeIcon, + tt.tt_TaskTypeColor AS TaskTypeColor, b.BusinessName FROM Tasks t LEFT JOIN TaskCategories tc ON tc.TaskCategoryID = t.TaskCategoryID + LEFT JOIN tt_TaskTypes tt ON tt.tt_TaskTypeID = t.TaskTypeID LEFT JOIN Businesses b ON b.BusinessID = t.TaskBusinessID WHERE #whereSQL# ORDER BY t.TaskClaimedOn DESC @@ -88,9 +94,19 @@ - - + + + + + + + + + + + + diff --git a/api/tasks/listPending.cfm b/api/tasks/listPending.cfm index 189bbf4..22ecae2 100644 --- a/api/tasks/listPending.cfm +++ b/api/tasks/listPending.cfm @@ -66,9 +66,13 @@ t.TaskAddedOn, t.TaskClaimedByUserID, tc.TaskCategoryName, - tc.TaskCategoryColor + tc.TaskCategoryColor, + tt.tt_TaskTypeName AS TaskTypeName, + tt.tt_TaskTypeIcon AS TaskTypeIcon, + tt.tt_TaskTypeColor AS TaskTypeColor FROM Tasks t LEFT JOIN TaskCategories tc ON tc.TaskCategoryID = t.TaskCategoryID + LEFT JOIN tt_TaskTypes tt ON tt.tt_TaskTypeID = t.TaskTypeID WHERE #whereSQL# ORDER BY t.TaskAddedOn ASC ", params, { datasource = "payfrit" })> @@ -100,7 +104,10 @@ "TaskSourceType": "order", "TaskSourceID": qTasks.TaskOrderID, "TaskCategoryName": len(trim(qTasks.TaskCategoryName)) ? qTasks.TaskCategoryName : "General", - "TaskCategoryColor": len(trim(qTasks.TaskCategoryColor)) ? qTasks.TaskCategoryColor : "##888888" + "TaskCategoryColor": len(trim(qTasks.TaskCategoryColor)) ? qTasks.TaskCategoryColor : "##888888", + "TaskTypeName": len(trim(qTasks.TaskTypeName)) ? qTasks.TaskTypeName : "", + "TaskTypeIcon": len(trim(qTasks.TaskTypeIcon)) ? qTasks.TaskTypeIcon : "notifications", + "TaskTypeColor": len(trim(qTasks.TaskTypeColor)) ? qTasks.TaskTypeColor : "##9C27B0" })> diff --git a/api/tasks/listTypes.cfm b/api/tasks/listTypes.cfm new file mode 100644 index 0000000..49f6c48 --- /dev/null +++ b/api/tasks/listTypes.cfm @@ -0,0 +1,86 @@ + + + + + +// Returns task types for a business +// Input: BusinessID (required) +// Output: { OK: true, TASK_TYPES: [...] } +// Each business has their own task types (no system defaults) + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID from body, header, or URL + businessID = 0; + httpHeaders = getHttpRequestData().headers; + + if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } else if (structKeyExists(httpHeaders, "X-Business-ID") && isNumeric(httpHeaders["X-Business-ID"])) { + businessID = int(httpHeaders["X-Business-ID"]); + } else if (structKeyExists(url, "BusinessID") && isNumeric(url.BusinessID)) { + businessID = int(url.BusinessID); + } + + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get task types for this business + q = queryExecute(" + SELECT + tt_TaskTypeID as TaskTypeID, + tt_TaskTypeName as TaskTypeName, + tt_TaskTypeDescription as TaskTypeDescription, + tt_TaskTypeIcon as TaskTypeIcon, + tt_TaskTypeColor as TaskTypeColor, + tt_TaskTypeSortOrder as SortOrder + FROM tt_TaskTypes + WHERE tt_TaskTypeBusinessID = :businessID + ORDER BY tt_TaskTypeSortOrder, tt_TaskTypeID + ", { + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + taskTypes = []; + for (row in q) { + arrayAppend(taskTypes, { + "TaskTypeID": row.TaskTypeID, + "TaskTypeName": row.TaskTypeName, + "TaskTypeDescription": isNull(row.TaskTypeDescription) ? "" : row.TaskTypeDescription, + "TaskTypeIcon": isNull(row.TaskTypeIcon) ? "notifications" : row.TaskTypeIcon, + "TaskTypeColor": isNull(row.TaskTypeColor) ? "##9C27B0" : row.TaskTypeColor + }); + } + + apiAbort({ + "OK": true, + "TASK_TYPES": taskTypes, + "COUNT": arrayLen(taskTypes) + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/tasks/reorderTypes.cfm b/api/tasks/reorderTypes.cfm new file mode 100644 index 0000000..3b37f4e --- /dev/null +++ b/api/tasks/reorderTypes.cfm @@ -0,0 +1,74 @@ + + + + + +// Update sort order for task types +// Input: BusinessID (required), Order (array of TaskTypeIDs in desired order) +// Output: { OK: true } + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID + businessID = 0; + if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get Order array + if (!structKeyExists(data, "Order") || !isArray(data.Order)) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Order array is required" }); + } + orderArray = data.Order; + + // Update sort order for each task type + sortOrder = 0; + for (taskTypeID in orderArray) { + if (isNumeric(taskTypeID)) { + sortOrder++; + queryExecute(" + UPDATE tt_TaskTypes + SET tt_TaskTypeSortOrder = :sortOrder + WHERE tt_TaskTypeID = :taskTypeID + AND tt_TaskTypeBusinessID = :businessID + ", { + sortOrder: { value: sortOrder, cfsqltype: "cf_sql_integer" }, + taskTypeID: { value: int(taskTypeID), cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + } + } + + apiAbort({ + "OK": true, + "MESSAGE": "Sort order updated" + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/api/tasks/saveType.cfm b/api/tasks/saveType.cfm new file mode 100644 index 0000000..de2922c --- /dev/null +++ b/api/tasks/saveType.cfm @@ -0,0 +1,152 @@ + + + + + +// Create or update a task type for a business +// Input: BusinessID (required), TaskTypeName (required), TaskTypeDescription (optional), TaskTypeIcon (optional), TaskTypeID (optional - for update) +// Output: { OK: true, TASK_TYPE_ID: ... } + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +try { + data = readJsonBody(); + + // Get BusinessID + businessID = 0; + if (structKeyExists(data, "BusinessID") && isNumeric(data.BusinessID)) { + businessID = int(data.BusinessID); + } + if (businessID == 0) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); + } + + // Get TaskTypeName + taskTypeName = ""; + if (structKeyExists(data, "TaskTypeName")) { + taskTypeName = trim(toString(data.TaskTypeName)); + } + if (!len(taskTypeName)) { + apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskTypeName is required" }); + } + if (len(taskTypeName) > 45) { + apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeName must be 45 characters or less" }); + } + + // Get TaskTypeDescription (optional) + taskTypeDescription = ""; + if (structKeyExists(data, "TaskTypeDescription")) { + taskTypeDescription = trim(toString(data.TaskTypeDescription)); + } + if (len(taskTypeDescription) > 100) { + apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeDescription must be 100 characters or less" }); + } + + // Get TaskTypeIcon (optional, default to 'notifications') + taskTypeIcon = "notifications"; + if (structKeyExists(data, "TaskTypeIcon") && len(trim(toString(data.TaskTypeIcon)))) { + taskTypeIcon = trim(toString(data.TaskTypeIcon)); + } + if (len(taskTypeIcon) > 30) { + apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeIcon must be 30 characters or less" }); + } + + // Get TaskTypeColor (optional, default to purple) + taskTypeColor = "##9C27B0"; + if (structKeyExists(data, "TaskTypeColor") && len(trim(toString(data.TaskTypeColor)))) { + taskTypeColor = trim(toString(data.TaskTypeColor)); + // Ensure it starts with # (incoming JSON has single #, we store single #) + if (left(taskTypeColor, 1) != chr(35)) { + taskTypeColor = chr(35) & taskTypeColor; + } + } + if (len(taskTypeColor) > 7) { + apiAbort({ "OK": false, "ERROR": "invalid_params", "MESSAGE": "TaskTypeColor must be a valid hex color" }); + } + + // Get TaskTypeID (optional - for update) + taskTypeID = 0; + if (structKeyExists(data, "TaskTypeID") && isNumeric(data.TaskTypeID)) { + taskTypeID = int(data.TaskTypeID); + } + + if (taskTypeID > 0) { + // UPDATE - verify it belongs to this business + qCheck = queryExecute(" + SELECT tt_TaskTypeID FROM tt_TaskTypes + WHERE tt_TaskTypeID = :taskTypeID + AND tt_TaskTypeBusinessID = :businessID + ", { + taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qCheck.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task type not found or does not belong to this business" }); + } + + queryExecute(" + UPDATE tt_TaskTypes + SET tt_TaskTypeName = :taskTypeName, + tt_TaskTypeDescription = :taskTypeDescription, + tt_TaskTypeIcon = :taskTypeIcon, + tt_TaskTypeColor = :taskTypeColor + WHERE tt_TaskTypeID = :taskTypeID + ", { + taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" }, + taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) }, + taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" }, + taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" }, + taskTypeID: { value: taskTypeID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + apiAbort({ + "OK": true, + "TASK_TYPE_ID": taskTypeID, + "MESSAGE": "Task type updated" + }); + } else { + // INSERT new task type + queryExecute(" + INSERT INTO tt_TaskTypes (tt_TaskTypeName, tt_TaskTypeDescription, tt_TaskTypeIcon, tt_TaskTypeColor, tt_TaskTypeBusinessID) + VALUES (:taskTypeName, :taskTypeDescription, :taskTypeIcon, :taskTypeColor, :businessID) + ", { + taskTypeName: { value: taskTypeName, cfsqltype: "cf_sql_varchar" }, + taskTypeDescription: { value: taskTypeDescription, cfsqltype: "cf_sql_varchar", null: !len(taskTypeDescription) }, + taskTypeIcon: { value: taskTypeIcon, cfsqltype: "cf_sql_varchar" }, + taskTypeColor: { value: taskTypeColor, cfsqltype: "cf_sql_varchar" }, + businessID: { value: businessID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + qNew = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" }); + newID = qNew.newID; + + apiAbort({ + "OK": true, + "TASK_TYPE_ID": newID, + "MESSAGE": "Task type created" + }); + } + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/portal/index.html b/portal/index.html index b4dd27e..9494385 100644 --- a/portal/index.html +++ b/portal/index.html @@ -24,14 +24,14 @@