diff --git a/api/Application.cfm b/api/Application.cfm index 7a44f61..8e1d4b3 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -1,6 +1,11 @@ + + + + + + try { + cached = appCacheGet("states", 86400); + if (!isNull(cached)) { + writeOutput(cached); + abort; + } + qStates = queryExecute(" SELECT tt_StateID as StateID, tt_StateAbbreviation as StateAbbreviation, tt_StateName as StateName FROM tt_States @@ -20,10 +26,12 @@ try { }); } - writeOutput(serializeJSON({ + jsonResponse = serializeJSON({ "OK": true, "STATES": states - })); + }); + appCachePut("states", jsonResponse); + writeOutput(jsonResponse); } catch (any e) { writeOutput(serializeJSON({ diff --git a/api/admin/perf.cfm b/api/admin/perf.cfm new file mode 100644 index 0000000..0fe9ab5 --- /dev/null +++ b/api/admin/perf.cfm @@ -0,0 +1,179 @@ + + + + + + +function apiAbort(payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +// Localhost-only protection +remoteAddr = cgi.REMOTE_ADDR; +if (remoteAddr != "127.0.0.1" && remoteAddr != "::1" && remoteAddr != "0:0:0:0:0:0:0:1" && remoteAddr != "10.10.0.2") { + apiAbort({ "OK": false, "ERROR": "forbidden" }); +} + +try { + // Parse parameters + view = structKeyExists(url, "view") ? lcase(url.view) : "count"; + hours = structKeyExists(url, "hours") ? val(url.hours) : 24; + if (hours <= 0 || hours > 720) hours = 24; + limitRows = structKeyExists(url, "limit") ? val(url.limit) : 20; + if (limitRows <= 0 || limitRows > 100) limitRows = 20; + + // Flush any buffered metrics first + flushPerfBuffer(); + + response = { "OK": true, "VIEW": view, "HOURS": hours }; + + if (view == "count") { + // Top endpoints by call count + q = queryExecute(" + SELECT + Endpoint, + COUNT(*) as Calls, + ROUND(AVG(TotalMs)) as AvgMs, + ROUND(AVG(DbMs)) as AvgDbMs, + ROUND(AVG(AppMs)) as AvgAppMs, + MAX(TotalMs) as MaxMs, + ROUND(AVG(QueryCount), 1) as AvgQueries, + ROUND(AVG(ResponseBytes)) as AvgBytes + FROM api_perf_log + WHERE LoggedAt > DATE_SUB(NOW(), INTERVAL :hours HOUR) + GROUP BY Endpoint + ORDER BY Calls DESC + LIMIT :lim + ", { + hours: { value: hours, cfsqltype: "cf_sql_integer" }, + lim: { value: limitRows, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + rows = []; + for (row in q) { + arrayAppend(rows, { + "Endpoint": row.Endpoint, + "Calls": row.Calls, + "AvgMs": row.AvgMs, + "AvgDbMs": row.AvgDbMs, + "AvgAppMs": row.AvgAppMs, + "MaxMs": row.MaxMs, + "AvgQueries": row.AvgQueries, + "AvgBytes": row.AvgBytes + }); + } + response["DATA"] = rows; + + } else if (view == "latency") { + // Top endpoints by average latency + q = queryExecute(" + SELECT + Endpoint, + COUNT(*) as Calls, + ROUND(AVG(TotalMs)) as AvgMs, + ROUND(AVG(DbMs)) as AvgDbMs, + ROUND(AVG(AppMs)) as AvgAppMs, + MAX(TotalMs) as MaxMs, + ROUND(AVG(QueryCount), 1) as AvgQueries + FROM api_perf_log + WHERE LoggedAt > DATE_SUB(NOW(), INTERVAL :hours HOUR) + GROUP BY Endpoint + HAVING Calls >= 3 + ORDER BY AvgMs DESC + LIMIT :lim + ", { + hours: { value: hours, cfsqltype: "cf_sql_integer" }, + lim: { value: limitRows, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + rows = []; + for (row in q) { + arrayAppend(rows, { + "Endpoint": row.Endpoint, + "Calls": row.Calls, + "AvgMs": row.AvgMs, + "AvgDbMs": row.AvgDbMs, + "AvgAppMs": row.AvgAppMs, + "MaxMs": row.MaxMs, + "AvgQueries": row.AvgQueries + }); + } + response["DATA"] = rows; + + } else if (view == "slow") { + // Slowest individual requests + q = queryExecute(" + SELECT Endpoint, TotalMs, DbMs, AppMs, QueryCount, ResponseBytes, + BusinessID, UserID, LoggedAt + FROM api_perf_log + WHERE LoggedAt > DATE_SUB(NOW(), INTERVAL :hours HOUR) + ORDER BY TotalMs DESC + LIMIT :lim + ", { + hours: { value: hours, cfsqltype: "cf_sql_integer" }, + lim: { value: limitRows, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + rows = []; + for (row in q) { + arrayAppend(rows, { + "Endpoint": row.Endpoint, + "TotalMs": row.TotalMs, + "DbMs": row.DbMs, + "AppMs": row.AppMs, + "QueryCount": row.QueryCount, + "ResponseBytes": row.ResponseBytes, + "BusinessID": row.BusinessID, + "UserID": row.UserID, + "LoggedAt": dateTimeFormat(row.LoggedAt, "yyyy-mm-dd HH:nn:ss") + }); + } + response["DATA"] = rows; + + } else if (view == "summary") { + // Overall summary stats + q = queryExecute(" + SELECT + COUNT(*) as TotalRequests, + COUNT(DISTINCT Endpoint) as UniqueEndpoints, + ROUND(AVG(TotalMs)) as OverallAvgMs, + MAX(TotalMs) as OverallMaxMs, + ROUND(AVG(DbMs)) as OverallAvgDbMs, + ROUND(AVG(AppMs)) as OverallAvgAppMs, + ROUND(AVG(QueryCount), 1) as OverallAvgQueries, + MIN(LoggedAt) as FirstLog, + MAX(LoggedAt) as LastLog + FROM api_perf_log + WHERE LoggedAt > DATE_SUB(NOW(), INTERVAL :hours HOUR) + ", { + hours: { value: hours, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + response["DATA"] = { + "TotalRequests": q.TotalRequests, + "UniqueEndpoints": q.UniqueEndpoints, + "OverallAvgMs": q.OverallAvgMs, + "OverallMaxMs": q.OverallMaxMs, + "OverallAvgDbMs": q.OverallAvgDbMs, + "OverallAvgAppMs": q.OverallAvgAppMs, + "OverallAvgQueries": q.OverallAvgQueries, + "FirstLog": isDate(q.FirstLog) ? dateTimeFormat(q.FirstLog, "yyyy-mm-dd HH:nn:ss") : "", + "LastLog": isDate(q.LastLog) ? dateTimeFormat(q.LastLog, "yyyy-mm-dd HH:nn:ss") : "" + }; + + } else { + apiAbort({ "OK": false, "ERROR": "invalid_view", "MESSAGE": "Use ?view=count|latency|slow|summary" }); + } + + apiAbort(response); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message, + "DETAIL": e.detail + }); +} + diff --git a/api/auth/validateToken.cfm b/api/auth/validateToken.cfm index 2251bdd..a6d587e 100644 --- a/api/auth/validateToken.cfm +++ b/api/auth/validateToken.cfm @@ -32,7 +32,7 @@ try { } // Look up the token - qToken = queryExecute(" + qToken = queryTimed(" SELECT ut.UserID, u.UserFirstName, u.UserLastName FROM UserTokens ut JOIN Users u ON u.UserID = ut.UserID @@ -47,7 +47,7 @@ try { userID = qToken.UserID; // Determine if user is a worker (has any active employment) - qWorker = queryExecute(" + qWorker = queryTimed(" SELECT COUNT(*) as cnt FROM lt_Users_Businesses_Employees WHERE UserID = :userID AND EmployeeIsActive = 1 @@ -55,6 +55,7 @@ try { userType = qWorker.cnt > 0 ? "worker" : "customer"; + logPerf(); apiAbort({ "OK": true, "UserID": userID, diff --git a/api/beacons/list.cfm b/api/beacons/list.cfm index d17ca86..2aaf6a9 100644 --- a/api/beacons/list.cfm +++ b/api/beacons/list.cfm @@ -81,4 +81,5 @@ if (structKeyExists(data, "onlyActive")) { })> + #serializeJSON({ OK=true, ERROR="", BusinessID=bizId, COUNT=arrayLen(beacons), BEACONS=beacons })# diff --git a/api/businesses/get.cfm b/api/businesses/get.cfm index 47a0889..c142359 100644 --- a/api/businesses/get.cfm +++ b/api/businesses/get.cfm @@ -32,8 +32,16 @@ try { abort; } + // Check cache (5 minute TTL) + cached = appCacheGet("biz_" & businessID, 300); + if (!isNull(cached)) { + logPerf(); + writeOutput(cached); + abort; + } + // Get business details - q = queryExecute(" + q = queryTimed(" SELECT BusinessID, BusinessName, @@ -55,7 +63,7 @@ try { } // Get address from Addresses table (either linked via AddressBusinessID or via Businesses.BusinessAddressID) - qAddr = queryExecute(" + qAddr = queryTimed(" SELECT a.AddressLine1, a.AddressLine2, a.AddressCity, a.AddressZIPCode, s.tt_StateAbbreviation FROM Addresses a LEFT JOIN tt_States s ON s.tt_StateID = a.AddressStateID @@ -87,7 +95,7 @@ try { } // Get hours from Hours table - qHours = queryExecute(" + qHours = queryTimed(" SELECT h.HoursDayID, h.HoursOpenTime, h.HoursClosingTime, d.tt_DayAbbrev FROM Hours h JOIN tt_Days d ON d.tt_DayID = h.HoursDayID @@ -152,9 +160,17 @@ try { response["OK"] = true; response["BUSINESS"] = business; + // Cache successful response + jsonResponse = serializeJSON(response); + appCachePut("biz_" & businessID, jsonResponse); + logPerf(); + writeOutput(jsonResponse); + abort; + } catch (any e) { response["ERROR"] = e.message; } +logPerf(); writeOutput(serializeJSON(response)); diff --git a/api/businesses/saveBrandColor.cfm b/api/businesses/saveBrandColor.cfm index e110e8e..92503d3 100644 --- a/api/businesses/saveBrandColor.cfm +++ b/api/businesses/saveBrandColor.cfm @@ -61,6 +61,7 @@ queryExecute(" bizId: { value: bizId, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); +appCacheInvalidate("biz_" & bizId); writeOutput(serializeJSON({ "OK": true, "ERROR": "", diff --git a/api/businesses/update.cfm b/api/businesses/update.cfm index 8f2255f..cc5ef55 100644 --- a/api/businesses/update.cfm +++ b/api/businesses/update.cfm @@ -126,6 +126,7 @@ try { } response.OK = true; + appCacheInvalidate("biz_" & businessId); } catch (any e) { response.ERROR = e.message; diff --git a/api/businesses/updateHours.cfm b/api/businesses/updateHours.cfm index e11b322..b3411ce 100644 --- a/api/businesses/updateHours.cfm +++ b/api/businesses/updateHours.cfm @@ -68,6 +68,7 @@ try { response.OK = true; response.hoursUpdated = arrayLen(hours); + appCacheInvalidate("biz_" & businessId); } catch (any e) { response.ERROR = e.message; diff --git a/api/config/environment.cfm b/api/config/environment.cfm index c9c325d..8451937 100644 --- a/api/config/environment.cfm +++ b/api/config/environment.cfm @@ -116,6 +116,142 @@ function apiError(message, detail = "", statusCode = 500) { return response; } +// ============================================ +// PERFORMANCE PROFILING +// ============================================ +application.perfEnabled = true; // Toggle profiling on/off + +// Initialize perf buffer (once per application start) +if (!structKeyExists(application, "perfBuffer")) { + application.perfBuffer = []; +} + +/** + * Drop-in replacement for queryExecute that tracks query count and time. + * Usage: queryTimed("SELECT ...", [...], { datasource: "payfrit" }) + */ +function queryTimed(required string sql, any params = [], struct options = {}) { + var t = getTickCount(); + var result = queryExecute(arguments.sql, arguments.params, arguments.options); + var elapsed = getTickCount() - t; + request._perf_queryCount++; + request._perf_queryTimeMs += elapsed; + return result; +} + +/** + * Flush the in-memory perf buffer to the api_perf_log MySQL table. + * Called automatically when buffer reaches 100 entries. + */ +function flushPerfBuffer() { + var batch = []; + lock name="payfrit_perfBuffer" timeout="2" type="exclusive" { + batch = duplicate(application.perfBuffer); + application.perfBuffer = []; + } + if (arrayLen(batch) == 0) return; + + try { + var sql = "INSERT INTO api_perf_log (Endpoint, TotalMs, DbMs, AppMs, QueryCount, ResponseBytes, BusinessID, UserID, LoggedAt) VALUES "; + var rows = []; + for (var m in batch) { + arrayAppend(rows, + "('" & replace(m.endpoint, "'", "''", "all") & "'," + & m.totalMs & "," & m.dbMs & "," & m.appMs & "," + & m.queryCount & "," & m.responseBytes & "," + & m.businessId & "," & m.userId & ",'" + & m.loggedAt & "')" + ); + } + sql &= arrayToList(rows, ","); + queryExecute(sql, {}, { datasource: "payfrit" }); + + // Cleanup: delete rows older than 30 days (run occasionally) + if (randRange(1, 100) == 1) { + queryExecute( + "DELETE FROM api_perf_log WHERE LoggedAt < DATE_SUB(NOW(), INTERVAL 30 DAY)", + {}, { datasource: "payfrit" } + ); + } + } catch (any e) { + // Silent fail - never break the app for profiling + } +} + +/** + * Log performance metrics for the current request. + * Call this at the end of any instrumented endpoint, before the final writeOutput/abort. + */ +function logPerf(numeric responseBytes = 0) { + if (!structKeyExists(application, "perfEnabled") || !application.perfEnabled) return; + + var totalMs = getTickCount() - request._perf_start; + var dbMs = request._perf_queryTimeMs; + + var metric = { + endpoint: structKeyExists(request, "_api_path") ? request._api_path : "unknown", + totalMs: totalMs, + dbMs: dbMs, + appMs: totalMs - dbMs, + queryCount: request._perf_queryCount, + responseBytes: arguments.responseBytes, + businessId: structKeyExists(request, "BusinessID") ? val(request.BusinessID) : 0, + userId: structKeyExists(request, "UserID") ? val(request.UserID) : 0, + loggedAt: dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss") + }; + + lock name="payfrit_perfBuffer" timeout="1" type="exclusive" { + arrayAppend(application.perfBuffer, metric); + if (arrayLen(application.perfBuffer) >= 100) { + thread name="perfFlush_#createUUID()#" { + flushPerfBuffer(); + } + } + } +} + +// ============================================ +// APPLICATION-SCOPE CACHE +// ============================================ +if (!structKeyExists(application, "cache")) { + application.cache = {}; +} + +/** + * Get a cached value. Returns null if not found or expired. + * Usage: cached = cacheGet("menu_123", 120); + * if (!isNull(cached)) { use it } else { query and cachePut } + */ +function appCacheGet(required string key, numeric ttlSeconds = 300) { + if (structKeyExists(application.cache, arguments.key)) { + var entry = application.cache[arguments.key]; + if (dateDiff("s", entry.cachedAt, now()) < arguments.ttlSeconds) { + return entry.data; + } + structDelete(application.cache, arguments.key); + } + return javaCast("null", ""); +} + +/** + * Store a value in cache. + */ +function appCachePut(required string key, required any data) { + application.cache[arguments.key] = { data: arguments.data, cachedAt: now() }; +} + +/** + * Invalidate all cache keys starting with the given prefix. + */ +function appCacheInvalidate(required string keyPrefix) { + var keys = structKeyArray(application.cache); + for (var k in keys) { + if (findNoCase(arguments.keyPrefix, k) == 1) { + structDelete(application.cache, k); + } + } +} + // Store in application scope application.isDevEnvironment = isDevEnvironment; application.isDev = isDev; diff --git a/api/menu/getForBuilder.cfm b/api/menu/getForBuilder.cfm index fb8534d..1d0271c 100644 --- a/api/menu/getForBuilder.cfm +++ b/api/menu/getForBuilder.cfm @@ -11,24 +11,34 @@ response = { "OK": false }; -// Recursive function to build nested options -function buildOptionsTree(allOptions, parentId) { - var result = []; +// Pre-index query rows by parentId for O(1) lookup instead of O(n) scan per level +function buildParentIndex(allOptions) { + var byParent = {}; for (var i = 1; i <= allOptions.recordCount; i++) { - if (allOptions.ParentItemID[i] == parentId) { - var children = buildOptionsTree(allOptions, allOptions.ItemID[i]); - arrayAppend(result, { - "id": "opt_" & allOptions.ItemID[i], - "dbId": allOptions.ItemID[i], - "name": allOptions.ItemName[i], - "price": allOptions.ItemPrice[i], - "isDefault": allOptions.IsDefault[i] == 1 ? true : false, - "sortOrder": allOptions.ItemSortOrder[i], - "requiresSelection": isNull(allOptions.RequiresSelection[i]) ? false : (allOptions.RequiresSelection[i] == 1), - "maxSelections": isNull(allOptions.MaxSelections[i]) ? 0 : allOptions.MaxSelections[i], - "options": children - }); - } + var pid = allOptions.ParentItemID[i]; + if (!structKeyExists(byParent, pid)) byParent[pid] = []; + arrayAppend(byParent[pid], i); + } + return byParent; +} + +// Recursive function to build nested options (uses pre-built index) +function buildOptionsTree(allOptions, parentId, byParent) { + if (!structKeyExists(byParent, parentId)) return []; + var result = []; + for (var idx in byParent[parentId]) { + var children = buildOptionsTree(allOptions, allOptions.ItemID[idx], byParent); + arrayAppend(result, { + "id": "opt_" & allOptions.ItemID[idx], + "dbId": allOptions.ItemID[idx], + "name": allOptions.ItemName[idx], + "price": allOptions.ItemPrice[idx], + "isDefault": allOptions.IsDefault[idx] == 1 ? true : false, + "sortOrder": allOptions.ItemSortOrder[idx], + "requiresSelection": isNull(allOptions.RequiresSelection[idx]) ? false : (allOptions.RequiresSelection[idx] == 1), + "maxSelections": isNull(allOptions.MaxSelections[idx]) ? 0 : allOptions.MaxSelections[idx], + "options": children + }); } if (arrayLen(result) > 1) { arraySort(result, function(a, b) { @@ -63,7 +73,7 @@ try { // Get all menus for this business allMenus = []; try { - qMenus = queryExecute(" + qMenus = queryTimed(" SELECT MenuID, MenuName, MenuDescription, MenuDaysActive, MenuStartTime, MenuEndTime, MenuSortOrder FROM Menus @@ -123,7 +133,7 @@ try { // Check if Categories table has data for this business hasCategoriesData = false; try { - qCatCheck = queryExecute(" + qCatCheck = queryTimed(" SELECT 1 FROM Categories WHERE CategoryBusinessID = :businessID LIMIT 1 ", { businessID: businessID }, { datasource: "payfrit" }); hasCategoriesData = (qCatCheck.recordCount > 0); @@ -141,7 +151,7 @@ try { menuParams["menuID"] = menuID; } - qCategories = queryExecute(" + qCategories = queryTimed(" SELECT CategoryID, CategoryName, @@ -153,7 +163,7 @@ try { ", menuParams, { datasource: "payfrit" }); // Get menu items - items that belong to categories (not modifiers) - qItems = queryExecute(" + qItems = queryTimed(" SELECT i.ItemID, i.ItemCategoryID as CategoryItemID, @@ -170,7 +180,7 @@ try { ", { businessID: businessID }, { datasource: "payfrit" }); // Get direct modifiers (items with ParentItemID pointing to menu items, not categories) - qDirectModifiers = queryExecute(" + qDirectModifiers = queryTimed(" SELECT m.ItemID, m.ItemParentItemID as ParentItemID, @@ -190,7 +200,7 @@ try { } else { // NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children - qCategories = queryExecute(" + qCategories = queryTimed(" SELECT DISTINCT p.ItemID as CategoryID, p.ItemName as CategoryName, @@ -206,7 +216,7 @@ try { ORDER BY p.ItemSortOrder, p.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); - qItems = queryExecute(" + qItems = queryTimed(" SELECT i.ItemID, i.ItemParentItemID as CategoryItemID, @@ -226,7 +236,7 @@ try { ORDER BY i.ItemSortOrder, i.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); - qDirectModifiers = queryExecute(" + qDirectModifiers = queryTimed(" SELECT m.ItemID, m.ItemParentItemID as ParentItemID, @@ -253,7 +263,7 @@ try { // Get template links ONLY for this business's menu items qTemplateLinks = queryNew("ParentItemID,TemplateItemID,SortOrder"); if (arrayLen(menuItemIds) > 0) { - qTemplateLinks = queryExecute(" + qTemplateLinks = queryTimed(" SELECT tl.ItemID as ParentItemID, tl.TemplateItemID, @@ -265,7 +275,7 @@ try { } // Get templates for this business only - qTemplates = queryExecute(" + qTemplates = queryTimed(" SELECT DISTINCT t.ItemID, t.ItemName, @@ -290,7 +300,7 @@ try { qTemplateChildren = queryNew("ItemID,ParentItemID,ItemName,ItemPrice,IsDefault,ItemSortOrder,RequiresSelection,MaxSelections"); if (arrayLen(templateIds) > 0) { - qTemplateChildren = queryExecute(" + qTemplateChildren = queryTimed(" SELECT c.ItemID, c.ItemParentItemID as ParentItemID, @@ -307,11 +317,12 @@ try { ", { templateIds: { value: arrayToList(templateIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" }); } - // Build templates lookup with their options + // Build templates lookup with their options (pre-index for fast tree building) + templateChildrenIndex = buildParentIndex(qTemplateChildren); templatesById = {}; for (i = 1; i <= qTemplates.recordCount; i++) { templateID = qTemplates.ItemID[i]; - options = buildOptionsTree(qTemplateChildren, templateID); + options = buildOptionsTree(qTemplateChildren, templateID, templateChildrenIndex); templatesById[templateID] = { "id": "mod_" & qTemplates.ItemID[i], "dbId": qTemplates.ItemID[i], @@ -343,10 +354,11 @@ try { } } - // Build nested direct modifiers for each menu item + // Build nested direct modifiers for each menu item (pre-index for fast tree building) + directModifiersIndex = buildParentIndex(qDirectModifiers); directModsByItem = {}; for (itemId in menuItemIds) { - options = buildOptionsTree(qDirectModifiers, itemId); + options = buildOptionsTree(qDirectModifiers, itemId, directModifiersIndex); if (arrayLen(options) > 0) { directModsByItem[itemId] = options; } @@ -441,7 +453,7 @@ try { // Get business brand color brandColor = ""; try { - qBrand = queryExecute(" + qBrand = queryTimed(" SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = :bizId ", { bizId: businessID }, { datasource: "payfrit" }); if (qBrand.recordCount > 0 && len(trim(qBrand.BusinessBrandColor))) { @@ -474,5 +486,6 @@ try { response["DETAIL"] = e.detail ?: ""; } +logPerf(); writeOutput(serializeJSON(response)); diff --git a/api/menu/items.cfm b/api/menu/items.cfm index a431975..780f5a8 100644 --- a/api/menu/items.cfm +++ b/api/menu/items.cfm @@ -48,6 +48,16 @@ + + + + + + + #cachedMenu# + + + @@ -57,7 +67,7 @@ - 0", [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" } @@ -74,7 +84,7 @@ - - - - - - @@ -360,6 +373,11 @@ + + + + + - - - - + + + + #menuResponse# + + #serializeJSON(payload)# diff --git a/api/orders/getDetail.cfm b/api/orders/getDetail.cfm index b08ebc6..3491ca6 100644 --- a/api/orders/getDetail.cfm +++ b/api/orders/getDetail.cfm @@ -42,7 +42,7 @@ try { } // Get order details - qOrder = queryExecute(" + qOrder = queryTimed(" SELECT o.OrderID, o.OrderBusinessID, @@ -78,7 +78,7 @@ try { } // Get line items (excluding deleted items) - qItems = queryExecute(" + qItems = queryTimed(" SELECT oli.OrderLineItemID, oli.OrderLineItemItemID, @@ -150,20 +150,16 @@ try { // Calculate total total = subtotal + tax + tip; - // Get staff who worked on this order (from Tasks table) with pending rating tokens - qStaff = queryExecute(" - SELECT DISTINCT u.UserID, u.UserFirstName, - (SELECT r.TaskRatingAccessToken - FROM TaskRatings r - INNER JOIN Tasks t2 ON t2.TaskID = r.TaskRatingTaskID - WHERE t2.TaskOrderID = :orderID - AND r.TaskRatingForUserID = u.UserID - AND r.TaskRatingDirection = 'customer_rates_worker' - AND r.TaskRatingCompletedOn IS NULL - AND r.TaskRatingExpiresOn > NOW() - LIMIT 1) AS RatingToken + // Get staff who worked on this order (LEFT JOIN instead of correlated subquery) + qStaff = queryTimed(" + SELECT DISTINCT u.UserID, u.UserFirstName, r.TaskRatingAccessToken AS RatingToken FROM Tasks t INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID + LEFT JOIN TaskRatings r ON r.TaskRatingTaskID = t.TaskID + AND r.TaskRatingForUserID = u.UserID + AND r.TaskRatingDirection = 'customer_rates_worker' + AND r.TaskRatingCompletedOn IS NULL + AND r.TaskRatingExpiresOn > NOW() WHERE t.TaskOrderID = :orderID AND t.TaskClaimedByUserID > 0 ", { orderID: orderID }); @@ -221,6 +217,7 @@ try { response["MESSAGE"] = e.message; } +logPerf(); writeOutput(serializeJSON(response)); // Helper functions diff --git a/api/orders/history.cfm b/api/orders/history.cfm index 85b05e5..54325ff 100644 --- a/api/orders/history.cfm +++ b/api/orders/history.cfm @@ -38,7 +38,7 @@ if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.U userToken = getHeader("X-User-Token"); if (len(userToken)) { try { - qTok = queryExecute( + qTok = queryTimed( "SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1", [ { value = userToken, cfsqltype = "cf_sql_varchar" } ], { datasource = "payfrit" } @@ -63,7 +63,7 @@ if (offset < 0) offset = 0; try { // Get orders for this user (exclude carts - status 0) - qOrders = queryExecute(" + qOrders = queryTimed(" SELECT o.OrderID, o.OrderUUID, @@ -88,29 +88,40 @@ try { }); // Get total count - qCount = queryExecute(" + qCount = queryTimed(" SELECT COUNT(*) as TotalCount FROM Orders WHERE OrderUserID = :userId AND OrderStatusID > 0 ", { userId: { value = userId, cfsqltype = "cf_sql_integer" } }); - // Build orders array with item counts and totals - orders = []; - for (row in qOrders) { - // Get line item count and calculate total - qItems = queryExecute(" + // Batch fetch line item counts and totals for ALL orders (eliminates N+1) + itemSummary = {}; + orderIds = valueList(qOrders.OrderID); + if (len(orderIds)) { + qAllItems = queryTimed(" SELECT + OrderLineItemOrderID, COUNT(*) as ItemCount, SUM(OrderLineItemQuantity * OrderLineItemPrice) as Subtotal FROM OrderLineItems - WHERE OrderLineItemOrderID = :orderId + WHERE OrderLineItemOrderID IN (:orderIds) AND OrderLineItemParentOrderLineItemID = 0 AND (OrderLineItemIsDeleted = 0 OR OrderLineItemIsDeleted IS NULL) - ", { orderId: { value = row.OrderID, cfsqltype = "cf_sql_integer" } }); + GROUP BY OrderLineItemOrderID + ", { orderIds: { value = orderIds, cfsqltype = "cf_sql_varchar", list = true } }); - itemCount = val(qItems.ItemCount); - subtotal = val(qItems.Subtotal); + for (r in qAllItems) { + itemSummary[r.OrderLineItemOrderID] = { count: r.ItemCount, subtotal: r.Subtotal }; + } + } + + // Build orders array + orders = []; + for (row in qOrders) { + summary = structKeyExists(itemSummary, row.OrderID) ? itemSummary[row.OrderID] : { count: 0, subtotal: 0 }; + itemCount = val(summary.count); + subtotal = val(summary.subtotal); tax = subtotal * 0.0875; total = subtotal + tax; @@ -156,6 +167,7 @@ try { }); } + logPerf(); writeOutput(serializeJSON({ "OK": true, "ORDERS": orders, diff --git a/api/orders/listForKDS.cfm b/api/orders/listForKDS.cfm index cbc6ac8..b280430 100644 --- a/api/orders/listForKDS.cfm +++ b/api/orders/listForKDS.cfm @@ -58,7 +58,7 @@ - - - - - + + + + - 0) - ORDER BY oli.OrderLineItemID - ", [ - { value = qOrders.OrderID, cfsqltype = "cf_sql_integer" }, - { value = StationID, cfsqltype = "cf_sql_integer" } - ], { datasource = "payfrit" })> + ORDER BY oli.OrderLineItemOrderID, oli.OrderLineItemID + ", [ { value = StationID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> - + ORDER BY oli.OrderLineItemOrderID, oli.OrderLineItemID + ", [], { datasource = "payfrit" })> - - - + + + + + + + + + + + @@ -207,6 +216,7 @@ })> + diff --git a/api/portal/stats.cfm b/api/portal/stats.cfm index eadef1d..0181805 100644 --- a/api/portal/stats.cfm +++ b/api/portal/stats.cfm @@ -36,63 +36,51 @@ try { todayStart = dateFormat(now(), "yyyy-mm-dd") & " 00:00:00"; todayEnd = dateFormat(now(), "yyyy-mm-dd") & " 23:59:59"; - // Orders today count - qOrdersToday = queryExecute(" - SELECT COUNT(*) as cnt - FROM Orders - WHERE OrderBusinessID = :businessID - AND OrderSubmittedOn >= :todayStart - AND OrderSubmittedOn <= :todayEnd + // Combined stats query — 1 round trip instead of 4 + qStats = queryTimed(" + SELECT + (SELECT COUNT(*) + FROM Orders + WHERE OrderBusinessID = :businessID + AND OrderSubmittedOn >= :todayStart + AND OrderSubmittedOn <= :todayEnd) as ordersToday, + + (SELECT COALESCE(SUM(li.OrderLineItemQuantity * li.OrderLineItemPrice), 0) + FROM Orders o + JOIN OrderLineItems li ON li.OrderLineItemOrderID = o.OrderID + WHERE o.OrderBusinessID = :businessID + AND o.OrderSubmittedOn >= :todayStart + AND o.OrderSubmittedOn <= :todayEnd + AND o.OrderStatusID >= 1) as revenueToday, + + (SELECT COUNT(*) + FROM Orders + WHERE OrderBusinessID = :businessID + AND OrderStatusID IN (1, 2)) as pendingOrders, + + (SELECT COUNT(*) + FROM Items + WHERE ItemBusinessID = :businessID + AND ItemIsActive = 1 + AND ItemParentItemID > 0) as menuItems ", { businessID: businessID, todayStart: { value: todayStart, cfsqltype: "cf_sql_varchar" }, todayEnd: { value: todayEnd, cfsqltype: "cf_sql_varchar" } }); - // Revenue today (sum of line items) - qRevenueToday = queryExecute(" - SELECT COALESCE(SUM(li.OrderLineItemQuantity * li.OrderLineItemPrice), 0) as total - FROM Orders o - JOIN OrderLineItems li ON li.OrderLineItemOrderID = o.OrderID - WHERE o.OrderBusinessID = :businessID - AND o.OrderSubmittedOn >= :todayStart - AND o.OrderSubmittedOn <= :todayEnd - AND o.OrderStatusID >= 1 - ", { - businessID: businessID, - todayStart: { value: todayStart, cfsqltype: "cf_sql_varchar" }, - todayEnd: { value: todayEnd, cfsqltype: "cf_sql_varchar" } - }); - - // Pending orders (status 1 = submitted, 2 = preparing) - qPendingOrders = queryExecute(" - SELECT COUNT(*) as cnt - FROM Orders - WHERE OrderBusinessID = :businessID - AND OrderStatusID IN (1, 2) - ", { businessID: businessID }); - - // Menu items count (active items that have a parent category, excluding categories themselves) - // Categories are items with ItemParentItemID = 0 and ItemIsCollapsible = 0 - qMenuItems = queryExecute(" - SELECT COUNT(*) as cnt - FROM Items - WHERE ItemBusinessID = :businessID - AND ItemIsActive = 1 - AND ItemParentItemID > 0 - ", { businessID: businessID }); - response["OK"] = true; response["STATS"] = { - "ordersToday": qOrdersToday.cnt, - "revenueToday": qRevenueToday.total, - "pendingOrders": qPendingOrders.cnt, - "menuItems": qMenuItems.cnt + "ordersToday": qStats.ordersToday, + "revenueToday": qStats.revenueToday, + "pendingOrders": qStats.pendingOrders, + "menuItems": qStats.menuItems }; } catch (any e) { response["ERROR"] = e.message; } +logPerf(); writeOutput(serializeJSON(response));