Add API performance profiling, caching, and query optimizations

- Add queryTimed() wrapper and logPerf() for per-endpoint timing metrics
- Add api_perf_log table flush mechanism with background thread batching
- Add application-scope cache (appCacheGet/Put/Invalidate) with TTL
- Cache businesses/get (5m), addresses/states (24h), menu/items (2m)
- Fix N+1 queries in orders/history, orders/listForKDS (batch fetch)
- Fix correlated subquery in orders/getDetail (LEFT JOIN)
- Combine 4 queries into 1 in portal/stats (subselects)
- Optimize getForBuilder tree building with pre-indexed parent lookup
- Add cache invalidation in update, saveBrandColor, updateHours, saveFromBuilder
- New admin/perf.cfm dashboard (localhost-protected)
- Instrument top 10 endpoints with queryTimed + logPerf

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-29 20:41:27 -08:00
parent a5fa1c1041
commit dc9db32b58
19 changed files with 554 additions and 156 deletions

View file

@ -1,6 +1,11 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<!--- Performance timing: start clock as early as possible --->
<cfset request._perf_start = getTickCount()>
<cfset request._perf_queryCount = 0>
<cfset request._perf_queryTimeMs = 0>
<!---
Payfrit API Application.cfm (updated 2026-01-22)

View file

@ -2,9 +2,15 @@
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<!--- List US states for address forms --->
<!--- List US states for address forms (cached 24h - static data) --->
<cfscript>
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({

179
api/admin/perf.cfm Normal file
View file

@ -0,0 +1,179 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
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
});
}
</cfscript>

View file

@ -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,

View file

@ -81,4 +81,5 @@ if (structKeyExists(data, "onlyActive")) {
})>
</cfloop>
<cfset logPerf()>
<cfoutput>#serializeJSON({ OK=true, ERROR="", BusinessID=bizId, COUNT=arrayLen(beacons), BEACONS=beacons })#</cfoutput>

View file

@ -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));
</cfscript>

View file

@ -61,6 +61,7 @@ queryExecute("
bizId: { value: bizId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
appCacheInvalidate("biz_" & bizId);
writeOutput(serializeJSON({
"OK": true,
"ERROR": "",

View file

@ -126,6 +126,7 @@ try {
}
response.OK = true;
appCacheInvalidate("biz_" & businessId);
} catch (any e) {
response.ERROR = e.message;

View file

@ -68,6 +68,7 @@ try {
response.OK = true;
response.hoursUpdated = arrayLen(hours);
appCacheInvalidate("biz_" & businessId);
} catch (any e) {
response.ERROR = e.message;

View file

@ -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;

View file

@ -11,25 +11,35 @@
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]);
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[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],
"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) {
return a.sortOrder - b.sortOrder;
@ -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));
</cfscript>

View file

@ -48,6 +48,16 @@
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
</cfif>
<!--- Check menu cache (2 minute TTL) --->
<cfset menuCacheKey = "menu_" & BusinessID & "_" & OrderTypeID & "_" & requestedMenuID>
<cfset cachedMenu = appCacheGet(menuCacheKey, 120)>
<cfif NOT isNull(cachedMenu)>
<cfset logPerf()>
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#cachedMenu#</cfoutput>
<cfabort>
</cfif>
<!--- Get current time and day for schedule filtering --->
<cfset currentTime = timeFormat(now(), "HH:mm:ss")>
<cfset currentDayID = dayOfWeek(now())>
@ -57,7 +67,7 @@
<!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
<cfset newSchemaActive = false>
<cftry>
<cfset qCheck = queryExecute(
<cfset qCheck = queryTimed(
"SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = ? AND ItemBusinessID > 0",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
@ -74,7 +84,7 @@
<!--- Check if Categories table has data for this business --->
<cfset hasCategoriesData = false>
<cftry>
<cfset qCatCheck = queryExecute(
<cfset qCatCheck = queryTimed(
"SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = ?",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
@ -91,7 +101,7 @@
<cfset activeMenuIds = "">
<cfset menuList = []>
<cftry>
<cfset qAllMenus = queryExecute(
<cfset qAllMenus = queryTimed(
"
SELECT MenuID, MenuName FROM Menus
WHERE MenuBusinessID = :bizId
@ -124,7 +134,7 @@
<!--- Get category headers as virtual items --->
<!--- Apply schedule filtering, order type filtering, and menu filtering --->
<cfset qCategories = queryExecute(
<cfset qCategories = queryTimed(
"
SELECT
CategoryID,
@ -178,7 +188,7 @@
<cfif len(trim(visibleCategoryIds)) EQ 0>
<cfset visibleCategoryIds = "0">
</cfif>
<cfset q = queryExecute(
<cfset q = queryTimed(
"
SELECT
i.ItemID,
@ -197,6 +207,7 @@
i.ItemStationID,
s.StationName,
s.StationColor
,COALESCE(c.CategoryMenuID, 0) as CategoryMenuID
FROM Items i
LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
@ -213,7 +224,7 @@
)>
<cfelse>
<!--- Fallback: Derive categories from parent Items --->
<cfset q = queryExecute(
<cfset q = queryTimed(
"
SELECT
i.ItemID,
@ -268,7 +279,7 @@
</cfif>
<cfelse>
<!--- OLD SCHEMA: Use Categories table --->
<cfset q = queryExecute(
<cfset q = queryTimed(
"
SELECT
i.ItemID,
@ -286,7 +297,8 @@
i.ItemSortOrder,
i.ItemStationID,
s.StationName,
s.StationColor
s.StationColor,
COALESCE(c.CategoryMenuID, 0) as CategoryMenuID
FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
@ -329,7 +341,8 @@
"ItemSortOrder": qCategories.CategorySortOrder,
"ItemStationID": "",
"ItemStationName": "",
"ItemStationColor": ""
"ItemStationColor": "",
"CategoryMenuID": isNull(qCategories.CategoryMenuID) ? 0 : val(qCategories.CategoryMenuID)
})>
</cfif>
</cfloop>
@ -360,6 +373,11 @@
</cfif>
</cfif>
<cfset itemMenuID = 0>
<cftry>
<cfset itemMenuID = isDefined("q.CategoryMenuID") ? val(q.CategoryMenuID) : 0>
<cfcatch><cfset itemMenuID = 0></cfcatch>
</cftry>
<cfset arrayAppend(rows, {
"ItemID": q.ItemID,
"ItemCategoryID": q.ItemCategoryID,
@ -376,14 +394,15 @@
"ItemSortOrder": q.ItemSortOrder,
"ItemStationID": len(trim(q.ItemStationID)) ? q.ItemStationID : "",
"ItemStationName": len(trim(q.StationName)) ? q.StationName : "",
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : ""
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : "",
"CategoryMenuID": itemMenuID
})>
</cfloop>
<!--- For unified schema: Add template-linked modifiers as virtual children of menu items --->
<cfif newSchemaActive>
<!--- Get template links: which menu items use which templates --->
<cfset qTemplateLinks = queryExecute(
<cfset qTemplateLinks = queryTimed(
"
SELECT
tl.ItemID as MenuItemID,
@ -406,7 +425,7 @@
)>
<!--- Get template options --->
<cfset qTemplateOptions = queryExecute(
<cfset qTemplateOptions = queryTimed(
"
SELECT DISTINCT
opt.ItemID as OptionItemID,
@ -514,7 +533,7 @@
<!--- Get brand color for this business --->
<cfset brandColor = "">
<cftry>
<cfset qBrand = queryExecute(
<cfset qBrand = queryTimed(
"SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = ?",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
@ -527,7 +546,7 @@
</cfcatch>
</cftry>
<cfset apiAbort({
<cfset menuResponse = serializeJSON({
"OK": true,
"ERROR": "",
"Items": rows,
@ -537,6 +556,11 @@
"Menus": menuList,
"SelectedMenuID": requestedMenuID
})>
<cfset appCachePut(menuCacheKey, menuResponse)>
<cfset logPerf()>
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#menuResponse#</cfoutput>
<cfabort>
<cfcatch>
<cfset apiAbort({

View file

@ -379,6 +379,9 @@ try {
}
response = { "OK": true, "SCHEMA": newSchemaActive ? "unified" : "legacy" };
// Invalidate menu cache for this business
appCacheInvalidate("menu_" & businessID);
appCacheInvalidate("menuBuilder_" & businessID);
} catch (any e) {
response = {

View file

@ -103,5 +103,6 @@
</cftry>
<!--- Return JSON response --->
<cfset logPerf()>
<cfcontent type="application/json" reset="true">
<cfoutput>#serializeJSON(payload)#</cfoutput>

View file

@ -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
// 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()
LIMIT 1) AS RatingToken
FROM Tasks t
INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID
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

View file

@ -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,

View file

@ -58,7 +58,7 @@
<cfif StationID GT 0>
<cfset stationParams = duplicate(params)>
<cfset arrayAppend(stationParams, { value = StationID, cfsqltype = "cf_sql_integer" })>
<cfset qOrders = queryExecute("
<cfset qOrders = queryTimed("
SELECT DISTINCT
o.OrderID,
o.OrderUUID,
@ -84,7 +84,7 @@
ORDER BY o.OrderSubmittedOn ASC, o.OrderID ASC
", stationParams, { datasource = "payfrit" })>
<cfelse>
<cfset qOrders = queryExecute("
<cfset qOrders = queryTimed("
SELECT
o.OrderID,
o.OrderUUID,
@ -109,12 +109,14 @@
<cfset orders = []>
<cfloop query="qOrders">
<!--- Get line items for this order --->
<!--- If filtering by station, only show items for that station (plus modifiers) --->
<!--- Batch fetch ALL line items for all orders in one query (eliminates N+1) --->
<cfset orderIds = valueList(qOrders.OrderID)>
<cfset lineItemsByOrder = {}>
<cfif len(orderIds)>
<cfif StationID GT 0>
<cfset qLineItems = queryExecute("
<cfset qAllLineItems = queryTimed("
SELECT
oli.OrderLineItemOrderID,
oli.OrderLineItemID,
oli.OrderLineItemParentOrderLineItemID,
oli.OrderLineItemItemID,
@ -130,17 +132,15 @@
FROM OrderLineItems oli
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
WHERE oli.OrderLineItemOrderID = ?
WHERE oli.OrderLineItemOrderID IN (#orderIds#)
AND oli.OrderLineItemIsDeleted = b'0'
AND (i.ItemStationID = ? OR i.ItemStationID = 0 OR i.ItemStationID IS NULL OR oli.OrderLineItemParentOrderLineItemID > 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" })>
<cfelse>
<cfset qLineItems = queryExecute("
<cfset qAllLineItems = queryTimed("
SELECT
oli.OrderLineItemOrderID,
oli.OrderLineItemID,
oli.OrderLineItemParentOrderLineItemID,
oli.OrderLineItemItemID,
@ -156,28 +156,37 @@
FROM OrderLineItems oli
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
WHERE oli.OrderLineItemOrderID = ?
WHERE oli.OrderLineItemOrderID IN (#orderIds#)
AND oli.OrderLineItemIsDeleted = b'0'
ORDER BY oli.OrderLineItemID
", [ { value = qOrders.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
ORDER BY oli.OrderLineItemOrderID, oli.OrderLineItemID
", [], { datasource = "payfrit" })>
</cfif>
<cfset lineItems = []>
<cfloop query="qLineItems">
<cfset arrayAppend(lineItems, {
"OrderLineItemID": qLineItems.OrderLineItemID,
"OrderLineItemParentOrderLineItemID": qLineItems.OrderLineItemParentOrderLineItemID,
"OrderLineItemItemID": qLineItems.OrderLineItemItemID,
"OrderLineItemPrice": qLineItems.OrderLineItemPrice,
"OrderLineItemQuantity": qLineItems.OrderLineItemQuantity,
"OrderLineItemRemark": qLineItems.OrderLineItemRemark,
"ItemName": qLineItems.ItemName,
"ItemParentItemID": qLineItems.ItemParentItemID,
"ItemParentName": qLineItems.ItemParentName,
"ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault,
"ItemStationID": qLineItems.ItemStationID
<!--- Group line items by OrderID --->
<cfloop query="qAllLineItems">
<cfset oid = qAllLineItems.OrderLineItemOrderID>
<cfif NOT structKeyExists(lineItemsByOrder, oid)>
<cfset lineItemsByOrder[oid] = []>
</cfif>
<cfset arrayAppend(lineItemsByOrder[oid], {
"OrderLineItemID": qAllLineItems.OrderLineItemID,
"OrderLineItemParentOrderLineItemID": qAllLineItems.OrderLineItemParentOrderLineItemID,
"OrderLineItemItemID": qAllLineItems.OrderLineItemItemID,
"OrderLineItemPrice": qAllLineItems.OrderLineItemPrice,
"OrderLineItemQuantity": qAllLineItems.OrderLineItemQuantity,
"OrderLineItemRemark": qAllLineItems.OrderLineItemRemark,
"ItemName": qAllLineItems.ItemName,
"ItemParentItemID": qAllLineItems.ItemParentItemID,
"ItemParentName": qAllLineItems.ItemParentName,
"ItemIsCheckedByDefault": qAllLineItems.ItemIsCheckedByDefault,
"ItemStationID": qAllLineItems.ItemStationID
})>
</cfloop>
</cfif>
<!--- Build orders array using pre-fetched line items --->
<cfloop query="qOrders">
<cfset lineItems = structKeyExists(lineItemsByOrder, qOrders.OrderID) ? lineItemsByOrder[qOrders.OrderID] : []>
<!--- Determine order type name --->
<cfset orderTypeName = "">
@ -207,6 +216,7 @@
})>
</cfloop>
<cfset logPerf()>
<cfset apiAbort({
"OK": true,
"ERROR": "",

View file

@ -45,7 +45,7 @@ try {
// Get businesses for this user
// Users are linked to businesses via BusinessUserID field (owner)
q = queryExecute("
q = queryTimed("
SELECT
b.BusinessID,
b.BusinessName
@ -71,5 +71,6 @@ try {
response["MESSAGE"] = e.message;
}
logPerf();
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -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
// 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
", {
businessID: businessID,
todayStart: { value: todayStart, cfsqltype: "cf_sql_varchar" },
todayEnd: { value: todayEnd, cfsqltype: "cf_sql_varchar" }
});
AND OrderSubmittedOn <= :todayEnd) as ordersToday,
// Revenue today (sum of line items)
qRevenueToday = queryExecute("
SELECT COALESCE(SUM(li.OrderLineItemQuantity * li.OrderLineItemPrice), 0) as total
(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
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" }
});
// 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));
</cfscript>