diff --git a/api/Application.cfm b/api/Application.cfm index b87fdd0..5bfa4dc 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -112,12 +112,14 @@ if (len(request._api_path)) { if (findNoCase("/api/tasks/getDetails.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/tasks/callserver", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/tasks/createChat.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/expirestalechats.cfm", request._api_path)) request._api_isPublic = true; // Chat endpoints if (findNoCase("/api/chat/getMessages.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/chat/sendMessage.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/chat/markRead.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/chat/getActiveChat.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/chat/closechat.cfm", request._api_path)) request._api_isPublic = true; // Token validation (for WebSocket server) if (findNoCase("/api/auth/validateToken.cfm", request._api_path)) request._api_isPublic = true; diff --git a/api/admin/closeAllChats.cfm b/api/admin/closeAllChats.cfm new file mode 100644 index 0000000..476d4fa --- /dev/null +++ b/api/admin/closeAllChats.cfm @@ -0,0 +1,26 @@ + + + +// Close all open chat tasks +try { + result = queryExecute(" + UPDATE Tasks + SET TaskCompletedOn = NOW() + WHERE TaskTypeID = 2 + AND TaskCompletedOn IS NULL + ", {}, { datasource: "payfrit" }); + + affected = result.recordCount ?: 0; + + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Closed all open chats", + "AFFECTED": affected + })); +} catch (any e) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": e.message + })); +} + diff --git a/api/admin/debugTasks.cfm b/api/admin/debugTasks.cfm index 5ae8c55..d528623 100644 --- a/api/admin/debugTasks.cfm +++ b/api/admin/debugTasks.cfm @@ -2,6 +2,25 @@ + +// Check for action parameter +data = {}; +try { + rawContent = getHttpRequestData().content; + if (len(trim(rawContent))) data = deserializeJSON(rawContent); +} catch (any e) {} + +// Close all open chats action +if (structKeyExists(data, "action") && data.action == "closeAllChats") { + queryExecute(" + UPDATE Tasks SET TaskCompletedOn = NOW() + WHERE TaskTypeID = 2 AND TaskCompletedOn IS NULL + ", {}, { datasource: "payfrit" }); + writeOutput(serializeJSON({ "OK": true, "MESSAGE": "All open chats closed" })); + abort; +} + + + + + + + + + + + + + + + + + + + + + + + + + + #serializeJSON(arguments.payload)# + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/orders/getActiveCart.cfm b/api/orders/getActiveCart.cfm new file mode 100644 index 0000000..e245f1e --- /dev/null +++ b/api/orders/getActiveCart.cfm @@ -0,0 +1,98 @@ + + + + + + +/** + * Get the user's active cart (status=0) if one exists + * Used at app startup to check if user has an existing order in progress + * + * Query params: + * UserID - the user's ID + * + * Returns the cart info including businessId, orderTypeId, and item count + */ + +response = { "OK": false }; + +try { + UserID = val(url.UserID ?: 0); + + if (UserID LTE 0) { + response["ERROR"] = "missing_user"; + response["MESSAGE"] = "UserID is required"; + writeOutput(serializeJSON(response)); + abort; + } + + // Get active cart (status = 0) for this user + qCart = queryExecute(" + SELECT + o.OrderID, + o.OrderUUID, + o.OrderBusinessID, + o.OrderTypeID, + o.OrderStatusID, + o.OrderServicePointID, + o.OrderAddedOn, + b.BusinessName, + b.BusinessOrderTypes, + sp.ServicePointName, + (SELECT COUNT(*) + FROM OrderLineItems oli + WHERE oli.OrderLineItemOrderID = o.OrderID + AND oli.OrderLineItemIsDeleted = 0 + AND oli.OrderLineItemParentOrderLineItemID = 0) as ItemCount + FROM Orders o + LEFT JOIN Businesses b ON b.BusinessID = o.OrderBusinessID + LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID + WHERE o.OrderUserID = :userId + AND o.OrderStatusID = 0 + ORDER BY o.OrderID DESC + LIMIT 1 + ", { + userId: { value: UserID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qCart.recordCount GT 0) { + orderTypeName = ""; + switch (qCart.OrderTypeID) { + case 0: orderTypeName = "Undecided"; break; + case 1: orderTypeName = "Dine-In"; break; + case 2: orderTypeName = "Takeaway"; break; + case 3: orderTypeName = "Delivery"; break; + } + + // Parse business order types (e.g., "1,2,3" -> array of ints) + businessOrderTypes = len(trim(qCart.BusinessOrderTypes)) ? qCart.BusinessOrderTypes : "1,2,3"; + orderTypesArray = listToArray(businessOrderTypes, ","); + + response["OK"] = true; + response["HAS_CART"] = true; + response["CART"] = { + "OrderID": qCart.OrderID, + "OrderUUID": qCart.OrderUUID, + "BusinessID": qCart.OrderBusinessID, + "BusinessName": len(trim(qCart.BusinessName)) ? qCart.BusinessName : "", + "BusinessOrderTypes": orderTypesArray, + "OrderTypeID": qCart.OrderTypeID, + "OrderTypeName": orderTypeName, + "ServicePointID": qCart.OrderServicePointID, + "ServicePointName": len(trim(qCart.ServicePointName)) ? qCart.ServicePointName : "", + "ItemCount": qCart.ItemCount, + "AddedOn": dateTimeFormat(qCart.OrderAddedOn, "yyyy-mm-dd HH:nn:ss") + }; + } else { + response["OK"] = true; + response["HAS_CART"] = false; + response["CART"] = javacast("null", ""); + } + +} catch (any e) { + response["ERROR"] = "server_error"; + response["MESSAGE"] = e.message; +} + +writeOutput(serializeJSON(response)); + diff --git a/api/orders/getCart.cfm b/api/orders/getCart.cfm index 3e70dca..a081186 100644 --- a/api/orders/getCart.cfm +++ b/api/orders/getCart.cfm @@ -64,13 +64,15 @@ - + + + diff --git a/api/orders/setLineItem.cfm b/api/orders/setLineItem.cfm index 3382005..1b01000 100644 --- a/api/orders/setLineItem.cfm +++ b/api/orders/setLineItem.cfm @@ -281,13 +281,34 @@ - + + + + + + + + + + + + + + + + + @@ -307,7 +328,13 @@ )> - + @@ -365,23 +392,28 @@ - - + + + + + + + diff --git a/api/orders/setOrderType.cfm b/api/orders/setOrderType.cfm index 8d66bdb..afeab37 100644 --- a/api/orders/setOrderType.cfm +++ b/api/orders/setOrderType.cfm @@ -90,19 +90,25 @@ @@ -146,12 +156,12 @@ })> - - + + @@ -202,29 +212,50 @@ - + + + + + + + diff --git a/api/tasks/createChat.cfm b/api/tasks/createChat.cfm index 7e446df..ad88e2d 100644 --- a/api/tasks/createChat.cfm +++ b/api/tasks/createChat.cfm @@ -39,6 +39,54 @@ try { apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" }); } + // Check for existing open chat for this user at this business + // 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) { + existingChat = queryExecute(" + SELECT t.TaskID, t.TaskAddedOn + FROM Tasks t + LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID + 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) + ) + 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" } + }, { 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) { + // Auto-close stale chat + queryExecute(" + UPDATE Tasks SET TaskCompletedOn = NOW() + WHERE TaskID = :taskID + ", { taskID: { value: existingChat.TaskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); + } else { + // Return existing chat + apiAbort({ + "OK": true, + "TaskID": existingChat.TaskID, + "MESSAGE": "Rejoined existing chat", + "EXISTING": true + }); + } + } + } + // Get service point info (table name) spQuery = queryExecute(" SELECT ServicePointName FROM ServicePoints WHERE ServicePointID = :spID diff --git a/api/tasks/expireStaleChats.cfm b/api/tasks/expireStaleChats.cfm new file mode 100644 index 0000000..fe9c637 --- /dev/null +++ b/api/tasks/expireStaleChats.cfm @@ -0,0 +1,74 @@ + + + + + + +// Scheduled task to expire stale chats (older than 20 minutes with no recent activity) +// Should be called every minute by a cron job or scheduled task + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +try { + // Find open chat tasks that are stale + // A chat is stale if: + // 1. TaskTypeID = 2 (Chat) + // 2. TaskCompletedOn IS NULL (not closed) + // 3. No messages in the last 20 minutes AND task is older than 20 minutes + + staleChats = queryExecute(" + SELECT t.TaskID, t.TaskAddedOn, + (SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.TaskID) as LastMessageOn + FROM Tasks t + WHERE t.TaskTypeID = 2 + AND t.TaskCompletedOn IS NULL + AND t.TaskAddedOn < DATE_SUB(NOW(), INTERVAL 20 MINUTE) + ", {}, { datasource: "payfrit" }); + + expiredCount = 0; + expiredIds = []; + + for (chat in staleChats) { + // Check last message time - if no messages or last message > 20 min ago, expire + shouldExpire = false; + + if (isNull(chat.LastMessageOn) || !isDate(chat.LastMessageOn)) { + // No messages at all - expire if task is old enough + shouldExpire = true; + } else { + // Has messages - check if last one is older than 20 minutes + lastMsgAge = dateDiff("n", chat.LastMessageOn, now()); + if (lastMsgAge > 20) { + shouldExpire = true; + } + } + + if (shouldExpire) { + queryExecute(" + UPDATE Tasks SET TaskCompletedOn = NOW() + WHERE TaskID = :taskID + ", { taskID: { value: chat.TaskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); + + expiredCount++; + arrayAppend(expiredIds, chat.TaskID); + } + } + + apiAbort({ + "OK": true, + "MESSAGE": "Expired #expiredCount# stale chat(s)", + "EXPIRED_TASK_IDS": expiredIds, + "CHECKED_COUNT": staleChats.recordCount + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/cron/expireStaleChats.cfm b/cron/expireStaleChats.cfm new file mode 100644 index 0000000..fe9c637 --- /dev/null +++ b/cron/expireStaleChats.cfm @@ -0,0 +1,74 @@ + + + + + + +// Scheduled task to expire stale chats (older than 20 minutes with no recent activity) +// Should be called every minute by a cron job or scheduled task + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +try { + // Find open chat tasks that are stale + // A chat is stale if: + // 1. TaskTypeID = 2 (Chat) + // 2. TaskCompletedOn IS NULL (not closed) + // 3. No messages in the last 20 minutes AND task is older than 20 minutes + + staleChats = queryExecute(" + SELECT t.TaskID, t.TaskAddedOn, + (SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.TaskID) as LastMessageOn + FROM Tasks t + WHERE t.TaskTypeID = 2 + AND t.TaskCompletedOn IS NULL + AND t.TaskAddedOn < DATE_SUB(NOW(), INTERVAL 20 MINUTE) + ", {}, { datasource: "payfrit" }); + + expiredCount = 0; + expiredIds = []; + + for (chat in staleChats) { + // Check last message time - if no messages or last message > 20 min ago, expire + shouldExpire = false; + + if (isNull(chat.LastMessageOn) || !isDate(chat.LastMessageOn)) { + // No messages at all - expire if task is old enough + shouldExpire = true; + } else { + // Has messages - check if last one is older than 20 minutes + lastMsgAge = dateDiff("n", chat.LastMessageOn, now()); + if (lastMsgAge > 20) { + shouldExpire = true; + } + } + + if (shouldExpire) { + queryExecute(" + UPDATE Tasks SET TaskCompletedOn = NOW() + WHERE TaskID = :taskID + ", { taskID: { value: chat.TaskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); + + expiredCount++; + arrayAppend(expiredIds, chat.TaskID); + } + } + + apiAbort({ + "OK": true, + "MESSAGE": "Expired #expiredCount# stale chat(s)", + "EXPIRED_TASK_IDS": expiredIds, + "CHECKED_COUNT": staleChats.recordCount + }); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} +