Add user account APIs and fix Lucee header handling

- Add avatar.cfm: GET/POST for user profile photos with multi-extension support
- Add profile.cfm: GET/POST for user profile (name, email, phone)
- Add history.cfm: Order history endpoint with pagination
- Add addresses/list.cfm and add.cfm: Delivery address management
- Add setOrderType.cfm: Set delivery/takeaway type on orders
- Add checkToken.cfm: Debug endpoint for token validation
- Fix headerValue() in Application.cfm to use servlet request object
  (Lucee CGI scope doesn't expose custom HTTP headers like X-User-Token)
- Update public allowlist for new endpoints
- Add privacy.html page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-08 20:01:07 -08:00
parent e757a4140b
commit d8d7efe056
14 changed files with 1458 additions and 16 deletions

View file

@ -42,8 +42,16 @@ function apiAbort(payload) {
} }
function headerValue(name) { function headerValue(name) {
// Use servlet request object to get headers (CGI scope doesn't expose custom HTTP headers in Lucee)
try {
req = getPageContext().getRequest();
val = req.getHeader(arguments.name);
if (!isNull(val)) return trim(val);
} catch (any e) {
// Fall back to CGI scope
k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all")); k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all"));
if (structKeyExists(cgi, k)) return trim(cgi[k]); if (structKeyExists(cgi, k)) return trim(cgi[k]);
}
return ""; return "";
} }
@ -64,6 +72,7 @@ if (len(request._api_path)) {
if (findNoCase("/api/auth/login.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/auth/login.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/logout.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/auth/logout.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/avatar.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true; 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/get.cfm", request._api_path)) request._api_isPublic = true;
@ -93,12 +102,22 @@ if (len(request._api_path)) {
if (findNoCase("/api/portal/stats.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/portal/stats.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/portal/myBusinesses.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/portal/myBusinesses.cfm", request._api_path)) request._api_isPublic = true;
// Order history (auth handled in endpoint)
if (findNoCase("/api/orders/history.cfm", request._api_path)) request._api_isPublic = true;
// User profile (auth handled in endpoint)
if (findNoCase("/api/auth/profile.cfm", request._api_path)) request._api_isPublic = true;
// Menu builder endpoints // Menu builder endpoints
if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/menu/getForBuilder.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/saveFromBuilder.cfm", request._api_path)) request._api_isPublic = true; 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/updateStations.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/tasks/create.cfm", request._api_path)) request._api_isPublic = true;
// Debug endpoints
if (findNoCase("/api/debug/checkToken.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/debug/headers.cfm", request._api_path)) request._api_isPublic = true;
// Admin endpoints (protected by localhost check in each file) // Admin endpoints (protected by localhost check in each file)
if (findNoCase("/api/admin/resetTestData.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/resetTestData.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugTasks.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/debugTasks.cfm", request._api_path)) request._api_isPublic = true;
@ -184,7 +203,16 @@ if (len(request._api_hdrBiz) && isNumeric(request._api_hdrBiz)) {
// Enforce auth (except public) // Enforce auth (except public)
if (!request._api_isPublic) { if (!request._api_isPublic) {
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) { if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) {
apiAbort({ "OK": false, "ERROR": "not_logged_in" }); apiAbort({
"OK": false,
"ERROR": "not_logged_in",
"DEBUG_PATH": request._api_path,
"DEBUG_IS_PUBLIC": request._api_isPublic,
"DEBUG_SCRIPT_NAME": structKeyExists(cgi, "SCRIPT_NAME") ? cgi.SCRIPT_NAME : "N/A",
"DEBUG_PATH_INFO": structKeyExists(cgi, "PATH_INFO") ? cgi.PATH_INFO : "N/A",
"DEBUG_HAS_TOKEN": len(request._api_userToken) > 0,
"DEBUG_TOKEN_PREFIX": len(request._api_userToken) > 8 ? left(request._api_userToken, 8) : request._api_userToken
});
} }
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) { if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
apiAbort({ "OK": false, "ERROR": "no_business_selected" }); apiAbort({ "OK": false, "ERROR": "no_business_selected" });

147
api/addresses/add.cfm Normal file
View file

@ -0,0 +1,147 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<!--- Add a new delivery address for the authenticated user --->
<cfscript>
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw) || len(trim(toString(raw))) == 0) return {};
try {
var data = deserializeJSON(toString(raw));
return isStruct(data) ? data : {};
} catch (any e) {
return {};
}
}
try {
// Get authenticated user ID from request context (set by Application.cfm)
userId = request.UserID ?: 0;
if (userId <= 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "unauthorized",
"MESSAGE": "Authentication required"
}));
abort;
}
data = readJsonBody();
// Required fields
line1 = trim(data.Line1 ?: "");
city = trim(data.City ?: "");
stateId = val(data.StateID ?: 0);
zipCode = trim(data.ZIPCode ?: "");
// Optional fields
line2 = trim(data.Line2 ?: "");
label = trim(data.Label ?: "");
setAsDefault = (data.SetAsDefault ?: false) == true;
// Validation
if (len(line1) == 0 || len(city) == 0 || stateId <= 0 || len(zipCode) == 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "missing_fields",
"MESSAGE": "Line1, City, StateID, and ZIPCode are required"
}));
abort;
}
// If setting as default, clear other defaults first
if (setAsDefault) {
queryExecute("
UPDATE Addresses
SET AddressIsDefaultDelivery = 0
WHERE AddressUserID = :userId
AND (AddressBusinessID = 0 OR AddressBusinessID IS NULL)
AND AddressTypeID LIKE '%2%'
", {
userId: { value: userId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
}
// Get next AddressID
qNext = queryExecute("SELECT IFNULL(MAX(AddressID), 0) + 1 AS NextID FROM Addresses", {}, { datasource: "payfrit" });
newAddressId = qNext.NextID;
// Insert new address
queryExecute("
INSERT INTO Addresses (
AddressID,
AddressUserID,
AddressBusinessID,
AddressTypeID,
AddressLabel,
AddressIsDefaultDelivery,
AddressLine1,
AddressLine2,
AddressCity,
AddressStateID,
AddressZIPCode,
AddressIsDeleted,
AddressAddedOn
) VALUES (
:addressId,
:userId,
0,
'2',
:label,
:isDefault,
:line1,
:line2,
:city,
:stateId,
:zipCode,
0,
:addedOn
)
", {
addressId: { value: newAddressId, cfsqltype: "cf_sql_integer" },
userId: { value: userId, cfsqltype: "cf_sql_integer" },
label: { value: label, cfsqltype: "cf_sql_varchar" },
isDefault: { value: setAsDefault ? 1 : 0, cfsqltype: "cf_sql_integer" },
line1: { value: line1, cfsqltype: "cf_sql_varchar" },
line2: { value: line2, cfsqltype: "cf_sql_varchar" },
city: { value: city, cfsqltype: "cf_sql_varchar" },
stateId: { value: stateId, cfsqltype: "cf_sql_integer" },
zipCode: { value: zipCode, cfsqltype: "cf_sql_varchar" },
addedOn: { value: now(), cfsqltype: "cf_sql_timestamp" }
}, { datasource: "payfrit" });
// Get state info for response
qState = queryExecute("SELECT StateAbbreviation, StateName FROM States WHERE StateID = :stateId", {
stateId: { value: stateId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
stateAbbr = qState.recordCount ? qState.StateAbbreviation : "";
stateName = qState.recordCount ? qState.StateName : "";
writeOutput(serializeJSON({
"OK": true,
"ADDRESS": {
"AddressID": newAddressId,
"Label": len(label) ? label : "Address",
"IsDefault": setAsDefault,
"Line1": line1,
"Line2": line2,
"City": city,
"StateID": stateId,
"StateAbbr": stateAbbr,
"StateName": stateName,
"ZIPCode": zipCode,
"DisplayText": line1 & (len(line2) ? ", " & line2 : "") & ", " & city & ", " & stateAbbr & " " & zipCode
}
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
}));
}
</cfscript>

73
api/addresses/list.cfm Normal file
View file

@ -0,0 +1,73 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<!--- List delivery addresses for the authenticated user --->
<cfscript>
try {
// Get authenticated user ID from request context (set by Application.cfm)
userId = request.UserID ?: 0;
if (userId <= 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "unauthorized",
"MESSAGE": "Authentication required"
}));
abort;
}
// Get user's delivery addresses (AddressTypeID contains "2" for delivery, BusinessID is 0 or NULL for personal)
qAddresses = queryExecute("
SELECT
a.AddressID,
a.AddressLabel,
a.AddressIsDefaultDelivery,
a.AddressLine1,
a.AddressLine2,
a.AddressCity,
a.AddressStateID,
s.StateAbbreviation,
s.StateName,
a.AddressZIPCode
FROM Addresses a
LEFT JOIN States s ON a.AddressStateID = s.StateID
WHERE a.AddressUserID = :userId
AND (a.AddressBusinessID = 0 OR a.AddressBusinessID IS NULL)
AND a.AddressTypeID LIKE '%2%'
AND a.AddressIsDeleted = 0
ORDER BY a.AddressIsDefaultDelivery DESC, a.AddressID DESC
", {
userId: { value: userId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
addresses = [];
for (row in qAddresses) {
arrayAppend(addresses, {
"AddressID": row.AddressID,
"Label": len(row.AddressLabel) ? row.AddressLabel : "Address",
"IsDefault": row.AddressIsDefaultDelivery == 1,
"Line1": row.AddressLine1,
"Line2": row.AddressLine2 ?: "",
"City": row.AddressCity,
"StateID": row.AddressStateID,
"StateAbbr": row.StateAbbreviation ?: "",
"StateName": row.StateName ?: "",
"ZIPCode": row.AddressZIPCode,
"DisplayText": row.AddressLine1 & (len(row.AddressLine2) ? ", " & row.AddressLine2 : "") & ", " & row.AddressCity & ", " & (row.StateAbbreviation ?: "") & " " & row.AddressZIPCode
});
}
writeOutput(serializeJSON({
"OK": true,
"ADDRESSES": addresses
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
}));
}
</cfscript>

182
api/auth/avatar.cfm Normal file
View file

@ -0,0 +1,182 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<!---
Avatar Upload/Get API
GET: Returns avatar URL for authenticated user
POST: Uploads new avatar image (multipart form data)
Stores images as: /uploads/users/{UserID}.jpg
--->
<cfscript>
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
// Helper to get header value - use servlet request object (CGI scope doesn't expose custom HTTP headers in Lucee)
function getHeader(name) {
try {
req = getPageContext().getRequest();
val = req.getHeader(arguments.name);
if (!isNull(val)) return trim(val);
} catch (any e) {
// Fall back to CGI scope
k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all"));
if (structKeyExists(cgi, k)) return trim(cgi[k]);
}
return "";
}
// Get authenticated user ID - try request scope first, then do our own token lookup
userId = 0;
debugInfo = {
"requestUserID": structKeyExists(request, "UserID") ? request.UserID : "not_set",
"headerToken": "",
"tokenLookupResult": "not_attempted"
};
if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.UserID > 0) {
userId = request.UserID;
debugInfo.source = "request_scope";
} else {
// Do our own token lookup for multipart requests
userToken = getHeader("X-User-Token");
debugInfo.headerToken = len(userToken) ? left(userToken, 8) & "..." : "empty";
if (len(userToken)) {
try {
qTok = queryExecute(
"SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1",
[ { value = userToken, cfsqltype = "cf_sql_varchar" } ],
{ datasource = "payfrit" }
);
debugInfo.tokenLookupResult = "found_#qTok.recordCount#_records";
if (qTok.recordCount EQ 1) {
userId = qTok.UserID;
debugInfo.source = "token_lookup";
}
} catch (any e) {
debugInfo.tokenLookupResult = "error: " & e.message;
}
}
}
if (userId <= 0) {
apiAbort({ "OK": false, "ERROR": "not_logged_in", "MESSAGE": "Authentication required", "DEBUG": debugInfo });
}
// Use absolute path from web root
uploadsPath = expandPath("/uploads/users/");
// Check for avatar with various extensions (case-insensitive)
function findAvatarFile(basePath, userId) {
extensions = ["jpg", "jpeg", "png", "gif", "webp", "JPG", "JPEG", "PNG", "GIF", "WEBP"];
for (ext in extensions) {
testPath = basePath & userId & "." & ext;
if (fileExists(testPath)) {
return { "exists": true, "path": testPath, "filename": userId & "." & ext };
}
}
return { "exists": false, "path": basePath & userId & ".jpg", "filename": userId & ".jpg" };
}
avatarInfo = findAvatarFile(uploadsPath, userId);
avatarPath = avatarInfo.path;
avatarFilename = avatarInfo.filename;
avatarUrl = "https://biz.payfrit.com/uploads/users/" & avatarFilename;
// Handle GET - return current avatar URL
if (cgi.REQUEST_METHOD == "GET") {
hasAvatar = avatarInfo.exists;
writeOutput(serializeJSON({
"OK": true,
"HAS_AVATAR": hasAvatar,
"AVATAR_URL": hasAvatar ? (avatarUrl & "?t=" & getTickCount()) : ""
}));
abort;
}
// Handle POST - upload new avatar
if (cgi.REQUEST_METHOD == "POST") {
try {
// Check if file was uploaded
if (!structKeyExists(form, "avatar") || !len(form.avatar)) {
apiAbort({ "OK": false, "ERROR": "missing_file", "MESSAGE": "No avatar file provided" });
}
// Get uploaded file info
uploadedFile = form.avatar;
// Ensure uploads directory exists
if (!directoryExists(uploadsPath)) {
directoryCreate(uploadsPath);
}
// Process the upload
uploadResult = fileUpload(
destination = uploadsPath,
fileField = "avatar",
nameConflict = "overwrite",
accept = "image/jpeg,image/png,image/gif,image/webp"
);
// Rename to userId.jpg (convert if needed)
uploadedPath = uploadsPath & uploadResult.serverFile;
// Read the image and save as JPEG
try {
img = imageRead(uploadedPath);
// Resize if too large (max 500x500 for avatars)
if (img.width > 500 || img.height > 500) {
if (img.width > img.height) {
imageScaleToFit(img, 500, "");
} else {
imageScaleToFit(img, "", 500);
}
}
// Save as JPEG
imageWrite(img, avatarPath, 0.85);
// Delete original if different from target
if (uploadedPath != avatarPath && fileExists(uploadedPath)) {
fileDelete(uploadedPath);
}
} catch (any imgErr) {
// If image processing fails, just rename the file
if (uploadedPath != avatarPath) {
if (fileExists(avatarPath)) {
fileDelete(avatarPath);
}
fileMove(uploadedPath, avatarPath);
}
}
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Avatar uploaded successfully",
"AVATAR_URL": avatarUrl & "?t=" & getTickCount()
}));
abort;
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "upload_error",
"MESSAGE": "Failed to upload avatar",
"DETAIL": e.message
});
}
}
// Unknown method
apiAbort({ "OK": false, "ERROR": "bad_method", "MESSAGE": "Use GET or POST" });
</cfscript>

171
api/auth/profile.cfm Normal file
View file

@ -0,0 +1,171 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* User Profile API
*
* GET: Returns current user's profile info
* POST: Updates profile (firstName, lastName)
*/
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
// Helper to get header value - use servlet request object (CGI scope doesn't expose custom HTTP headers in Lucee)
function getHeader(name) {
try {
req = getPageContext().getRequest();
val = req.getHeader(arguments.name);
if (!isNull(val)) return trim(val);
} catch (any e) {
// Fall back to CGI scope
k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all"));
if (structKeyExists(cgi, k)) return trim(cgi[k]);
}
return "";
}
// Get authenticated user - try request scope first, then do token lookup
userId = 0;
if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.UserID > 0) {
userId = request.UserID;
} else {
userToken = getHeader("X-User-Token");
if (len(userToken)) {
try {
qTok = queryExecute(
"SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1",
[ { value = userToken, cfsqltype = "cf_sql_varchar" } ],
{ datasource = "payfrit" }
);
if (qTok.recordCount EQ 1) {
userId = qTok.UserID;
}
} catch (any e) { /* ignore */ }
}
}
if (userId <= 0) {
apiAbort({ "OK": false, "ERROR": "not_logged_in", "MESSAGE": "Authentication required" });
}
// Handle GET - return profile
if (cgi.REQUEST_METHOD == "GET") {
try {
qUser = queryExecute("
SELECT
UserID,
UserFirstName,
UserLastName,
UserEmailAddress,
UserContactNumber
FROM Users
WHERE UserID = :userId
LIMIT 1
", { userId: { value = userId, cfsqltype = "cf_sql_integer" } });
if (qUser.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "user_not_found", "MESSAGE": "User not found" });
}
writeOutput(serializeJSON({
"OK": true,
"USER": {
"UserID": qUser.UserID,
"FirstName": qUser.UserFirstName ?: "",
"LastName": qUser.UserLastName ?: "",
"Email": qUser.UserEmailAddress ?: "",
"Phone": qUser.UserContactNumber ?: ""
}
}));
abort;
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Failed to load profile",
"DETAIL": e.message
});
}
}
// Handle POST - update profile
if (cgi.REQUEST_METHOD == "POST") {
try {
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
apiAbort({ "OK": false, "ERROR": "missing_body", "MESSAGE": "Request body required" });
}
data = deserializeJSON(requestBody);
// Build update fields
updates = [];
params = { userId: { value = userId, cfsqltype = "cf_sql_integer" } };
if (structKeyExists(data, "firstName")) {
arrayAppend(updates, "UserFirstName = :firstName");
params.firstName = { value = data.firstName, cfsqltype = "cf_sql_varchar" };
}
if (structKeyExists(data, "lastName")) {
arrayAppend(updates, "UserLastName = :lastName");
params.lastName = { value = data.lastName, cfsqltype = "cf_sql_varchar" };
}
if (arrayLen(updates) == 0) {
apiAbort({ "OK": false, "ERROR": "no_changes", "MESSAGE": "No fields to update" });
}
// Execute update
queryExecute("
UPDATE Users
SET #arrayToList(updates, ', ')#
WHERE UserID = :userId
", params);
// Return updated profile
qUser = queryExecute("
SELECT
UserID,
UserFirstName,
UserLastName,
UserEmailAddress,
UserContactNumber
FROM Users
WHERE UserID = :userId
LIMIT 1
", { userId: { value = userId, cfsqltype = "cf_sql_integer" } });
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Profile updated",
"USER": {
"UserID": qUser.UserID,
"FirstName": qUser.UserFirstName ?: "",
"LastName": qUser.UserLastName ?: "",
"Email": qUser.UserEmailAddress ?: "",
"Phone": qUser.UserContactNumber ?: ""
}
}));
abort;
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Failed to update profile",
"DETAIL": e.message
});
}
}
// Unknown method
apiAbort({ "OK": false, "ERROR": "bad_method", "MESSAGE": "Use GET or POST" });
</cfscript>

73
api/debug/checkToken.cfm Normal file
View file

@ -0,0 +1,73 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
function headerValue(name) {
// Use servlet request object to get headers (CGI scope doesn't expose custom HTTP headers in Lucee)
try {
req = getPageContext().getRequest();
val = req.getHeader(arguments.name);
if (!isNull(val)) return trim(val);
} catch (any e) {
// Fall back to CGI scope
k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all"));
if (structKeyExists(cgi, k)) return trim(cgi[k]);
}
return "";
}
userToken = headerValue("X-User-Token");
result = {
"receivedToken": userToken,
"tokenLength": len(userToken),
"tokenPrefix": len(userToken) > 8 ? left(userToken, 8) : userToken
};
if (len(userToken)) {
try {
qTok = queryExecute(
"SELECT UserID, Token FROM UserTokens WHERE Token = ? LIMIT 1",
[ { value = userToken, cfsqltype = "cf_sql_varchar" } ],
{ datasource = "payfrit" }
);
result.dbLookupRecords = qTok.recordCount;
if (qTok.recordCount > 0) {
result.foundUserId = qTok.UserID;
}
// Also check with LIKE to see if partial match exists
qPartial = queryExecute(
"SELECT UserID, Token FROM UserTokens WHERE Token LIKE ? LIMIT 5",
[ { value = left(userToken, 8) & "%", cfsqltype = "cf_sql_varchar" } ],
{ datasource = "payfrit" }
);
result.partialMatches = qPartial.recordCount;
} catch (any e) {
result.error = e.message;
}
}
// Also list recent tokens
try {
qRecent = queryExecute(
"SELECT UserID, LEFT(Token, 8) as TokenPrefix, LENGTH(Token) as TokenLen FROM UserTokens ORDER BY UserID DESC LIMIT 5",
[],
{ datasource = "payfrit" }
);
result.recentTokens = [];
for (row in qRecent) {
arrayAppend(result.recentTokens, {
"userId": row.UserID,
"prefix": row.TokenPrefix,
"length": row.TokenLen
});
}
} catch (any e) {
result.recentTokensError = e.message;
}
writeOutput(serializeJSON(result));
</cfscript>

View file

@ -64,6 +64,14 @@
<cfset apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Order not found.", "DETAIL": "" })> <cfset apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Order not found.", "DETAIL": "" })>
</cfif> </cfif>
<!--- Get business delivery fee for display in cart --->
<cfset qBusiness = queryExecute(
"SELECT BusinessDeliveryFlatFee FROM Businesses WHERE BusinessID = ? LIMIT 1",
[ { value = qOrder.OrderBusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset businessDeliveryFee = qBusiness.recordCount GT 0 ? qBusiness.BusinessDeliveryFlatFee : 0>
<cfset qLI = queryExecute( <cfset qLI = queryExecute(
" "
SELECT SELECT
@ -112,6 +120,7 @@
"OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier, "OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier,
"OrderTypeID": qOrder.OrderTypeID, "OrderTypeID": qOrder.OrderTypeID,
"OrderDeliveryFee": qOrder.OrderDeliveryFee, "OrderDeliveryFee": qOrder.OrderDeliveryFee,
"BusinessDeliveryFee": businessDeliveryFee,
"OrderStatusID": qOrder.OrderStatusID, "OrderStatusID": qOrder.OrderStatusID,
"OrderAddressID": qOrder.OrderAddressID, "OrderAddressID": qOrder.OrderAddressID,
"OrderPaymentID": qOrder.OrderPaymentID, "OrderPaymentID": qOrder.OrderPaymentID,

View file

@ -60,6 +60,14 @@
<cfreturn { "OK": false, "ERROR": "not_found", "MESSAGE": "Order not found", "DETAIL": "" }> <cfreturn { "OK": false, "ERROR": "not_found", "MESSAGE": "Order not found", "DETAIL": "" }>
</cfif> </cfif>
<!--- Get business delivery fee for display in cart --->
<cfset var qBusiness = queryExecute(
"SELECT BusinessDeliveryFlatFee FROM Businesses WHERE BusinessID = ? LIMIT 1",
[ { value = qOrder.OrderBusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset var businessDeliveryFee = qBusiness.recordCount GT 0 ? qBusiness.BusinessDeliveryFlatFee : 0>
<cfset out.Order = { <cfset out.Order = {
"OrderID": qOrder.OrderID, "OrderID": qOrder.OrderID,
"OrderUUID": qOrder.OrderUUID, "OrderUUID": qOrder.OrderUUID,
@ -68,6 +76,7 @@
"OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier, "OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier,
"OrderTypeID": qOrder.OrderTypeID, "OrderTypeID": qOrder.OrderTypeID,
"OrderDeliveryFee": qOrder.OrderDeliveryFee, "OrderDeliveryFee": qOrder.OrderDeliveryFee,
"BusinessDeliveryFee": businessDeliveryFee,
"OrderStatusID": qOrder.OrderStatusID, "OrderStatusID": qOrder.OrderStatusID,
"OrderAddressID": qOrder.OrderAddressID, "OrderAddressID": qOrder.OrderAddressID,
"OrderPaymentID": qOrder.OrderPaymentID, "OrderPaymentID": qOrder.OrderPaymentID,
@ -128,38 +137,81 @@
<cfset OrderTypeID = val( structKeyExists(data,"OrderTypeID") ? data.OrderTypeID : 0 )> <cfset OrderTypeID = val( structKeyExists(data,"OrderTypeID") ? data.OrderTypeID : 0 )>
<cfset OrderUserID = val( structKeyExists(data,"OrderUserID") ? data.OrderUserID : 0 )> <cfset OrderUserID = val( structKeyExists(data,"OrderUserID") ? data.OrderUserID : 0 )>
<cfif BusinessID LTE 0 OR OrderServicePointID LTE 0 OR OrderTypeID NEQ 1 OR OrderUserID LTE 0> <!--- OrderTypeID: 0=undecided, 1=dine-in, 2=takeaway, 3=delivery --->
<cfif BusinessID LTE 0 OR OrderUserID LTE 0>
<cfset apiAbort({ <cfset apiAbort({
"OK": false, "OK": false,
"ERROR": "missing_params", "ERROR": "missing_params",
"MESSAGE": "BusinessID, OrderServicePointID, OrderTypeID=1, and OrderUserID are required.", "MESSAGE": "BusinessID and OrderUserID are required.",
"DETAIL": ""
})>
</cfif>
<!--- OrderTypeID can be 0 (undecided) for delivery/takeaway flow, or 1 for dine-in --->
<cfif OrderTypeID LT 0 OR OrderTypeID GT 3>
<cfset apiAbort({
"OK": false,
"ERROR": "invalid_order_type",
"MESSAGE": "OrderTypeID must be 0-3 (0=undecided, 1=dine-in, 2=takeaway, 3=delivery).",
"DETAIL": "" "DETAIL": ""
})> })>
</cfif> </cfif>
<cftry> <cftry>
<!--- Find existing cart (OrderStatusID=0 assumed cart) ---> <!--- Find existing cart (OrderStatusID=0 assumed cart) --->
<!--- Look for any active cart for this user/business - order type can be changed later --->
<cfset qFind = queryExecute( <cfset qFind = queryExecute(
" "
SELECT OrderID SELECT OrderID, OrderTypeID
FROM Orders FROM Orders
WHERE OrderUserID = ? WHERE OrderUserID = ?
AND OrderBusinessID = ? AND OrderBusinessID = ?
AND OrderTypeID = 1
AND OrderStatusID = 0 AND OrderStatusID = 0
AND OrderServicePointID = ?
ORDER BY OrderID DESC ORDER BY OrderID DESC
LIMIT 1 LIMIT 1
", ",
[ [
{ value = OrderUserID, cfsqltype = "cf_sql_integer" }, { value = OrderUserID, cfsqltype = "cf_sql_integer" },
{ value = BusinessID, cfsqltype = "cf_sql_integer" }, { value = BusinessID, cfsqltype = "cf_sql_integer" }
{ value = OrderServicePointID, cfsqltype = "cf_sql_integer" }
], ],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<cfif qFind.recordCount GT 0> <cfif qFind.recordCount GT 0>
<!--- Always update the service point to match the current table/beacon --->
<cfif OrderServicePointID GT 0>
<cfset queryExecute(
"UPDATE Orders SET OrderServicePointID = ?, OrderLastEditedOn = ? WHERE OrderID = ?",
[
{ value = OrderServicePointID, cfsqltype = "cf_sql_integer" },
{ value = now(), cfsqltype = "cf_sql_timestamp" },
{ value = qFind.OrderID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
</cfif>
<!--- Check if cart order type differs from requested and cart is empty --->
<!--- If so, update the cart's order type to match the new flow --->
<cfif qFind.OrderTypeID NEQ OrderTypeID>
<cfset qLineItems = queryExecute(
"SELECT COUNT(*) AS ItemCount FROM OrderLineItems WHERE OrderLineItemOrderID = ? AND OrderLineItemIsDeleted = 0",
[ { value = qFind.OrderID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Only update order type if cart is empty (allows switching flows) --->
<cfif qLineItems.ItemCount EQ 0>
<cfset queryExecute(
"UPDATE Orders SET OrderTypeID = ?, OrderLastEditedOn = ? WHERE OrderID = ?",
[
{ value = OrderTypeID, cfsqltype = "cf_sql_integer" },
{ value = now(), cfsqltype = "cf_sql_timestamp" },
{ value = qFind.OrderID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
</cfif>
</cfif>
<cfset payload = loadCartPayload(qFind.OrderID)> <cfset payload = loadCartPayload(qFind.OrderID)>
<cfset apiAbort(payload)> <cfset apiAbort(payload)>
</cfif> </cfif>
@ -183,10 +235,11 @@
<cfset nowDt = now()> <cfset nowDt = now()>
<cfset newUUID = createUUID()> <cfset newUUID = createUUID()>
<!--- Calculate delivery fee: only for delivery orders (OrderTypeID = 2) <!--- Calculate delivery fee: only for delivery orders (OrderTypeID = 3)
OrderTypeID: 1=dine-in, 2=delivery, 3=takeaway, 4=ship-to (assumed) OrderTypeID: 0=undecided, 1=dine-in, 2=takeaway, 3=delivery
Only delivery (2) should have delivery fee ---> Only delivery (3) should have delivery fee.
<cfset deliveryFee = (OrderTypeID EQ 2) ? qBiz.BusinessDeliveryFlatFee : 0> Note: For undecided orders (0), fee is set later via setOrderType.cfm --->
<cfset deliveryFee = (OrderTypeID EQ 3) ? qBiz.BusinessDeliveryFlatFee : 0>
<!--- Generate new OrderID (table is not auto-inc in SSOT) ---> <!--- Generate new OrderID (table is not auto-inc in SSOT) --->
<cfset qNext = queryExecute( <cfset qNext = queryExecute(

View file

@ -0,0 +1,109 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Get pending orders for a user at a specific business
* Used when a beacon is detected to check if user has an order to pick up
*
* Query params:
* UserID - the user's ID
* BusinessID - the business ID
*
* Returns orders with status 1-3 (Submitted, Preparing, Ready)
*/
response = { "OK": false };
try {
UserID = val(url.UserID ?: 0);
BusinessID = val(url.BusinessID ?: 0);
if (UserID LTE 0) {
response["ERROR"] = "missing_user";
response["MESSAGE"] = "UserID is required";
writeOutput(serializeJSON(response));
abort;
}
if (BusinessID LTE 0) {
response["ERROR"] = "missing_business";
response["MESSAGE"] = "BusinessID is required";
writeOutput(serializeJSON(response));
abort;
}
// Get orders with status 1 (Submitted), 2 (Preparing), or 3 (Ready)
// These are orders that are paid but not yet completed/picked up
qOrders = queryExecute("
SELECT
o.OrderID,
o.OrderUUID,
o.OrderTypeID,
o.OrderStatusID,
o.OrderSubmittedOn,
o.OrderServicePointID,
sp.ServicePointName,
b.BusinessName,
(SELECT COALESCE(SUM(oli.OrderLineItemPrice * oli.OrderLineItemQuantity), 0)
FROM OrderLineItems oli
WHERE oli.OrderLineItemOrderID = o.OrderID
AND oli.OrderLineItemIsDeleted = 0
AND oli.OrderLineItemParentOrderLineItemID = 0) as Subtotal
FROM Orders o
LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID
LEFT JOIN Businesses b ON b.BusinessID = o.OrderBusinessID
WHERE o.OrderUserID = :userId
AND o.OrderBusinessID = :businessId
AND o.OrderStatusID IN (1, 2, 3)
ORDER BY o.OrderSubmittedOn DESC
LIMIT 5
", {
userId: { value: UserID, cfsqltype: "cf_sql_integer" },
businessId: { value: BusinessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
orders = [];
for (row in qOrders) {
statusName = "";
switch (row.OrderStatusID) {
case 1: statusName = "Submitted"; break;
case 2: statusName = "Preparing"; break;
case 3: statusName = "Ready for Pickup"; break;
}
orderTypeName = "";
switch (row.OrderTypeID) {
case 1: orderTypeName = "Dine-In"; break;
case 2: orderTypeName = "Takeaway"; break;
case 3: orderTypeName = "Delivery"; break;
}
arrayAppend(orders, {
"OrderID": row.OrderID,
"OrderUUID": row.OrderUUID,
"OrderTypeID": row.OrderTypeID,
"OrderTypeName": orderTypeName,
"OrderStatusID": row.OrderStatusID,
"StatusName": statusName,
"SubmittedOn": dateTimeFormat(row.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss"),
"ServicePointID": row.OrderServicePointID,
"ServicePointName": len(trim(row.ServicePointName)) ? row.ServicePointName : "",
"BusinessName": len(trim(row.BusinessName)) ? row.BusinessName : "",
"Subtotal": row.Subtotal
});
}
response["OK"] = true;
response["ORDERS"] = orders;
response["HAS_PENDING"] = arrayLen(orders) GT 0;
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

160
api/orders/history.cfm Normal file
View file

@ -0,0 +1,160 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Order History API
* Returns list of completed/submitted orders for the authenticated user
*
* GET: ?limit=20&offset=0
*/
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
// Helper to get header value - use servlet request object (CGI scope doesn't expose custom HTTP headers in Lucee)
function getHeader(name) {
try {
req = getPageContext().getRequest();
val = req.getHeader(arguments.name);
if (!isNull(val)) return trim(val);
} catch (any e) {
// Fall back to CGI scope
k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all"));
if (structKeyExists(cgi, k)) return trim(cgi[k]);
}
return "";
}
// Get authenticated user - try request scope first, then do token lookup
userId = 0;
if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.UserID > 0) {
userId = request.UserID;
} else {
userToken = getHeader("X-User-Token");
if (len(userToken)) {
try {
qTok = queryExecute(
"SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1",
[ { value = userToken, cfsqltype = "cf_sql_varchar" } ],
{ datasource = "payfrit" }
);
if (qTok.recordCount EQ 1) {
userId = qTok.UserID;
}
} catch (any e) { /* ignore */ }
}
}
if (userId <= 0) {
apiAbort({ "OK": false, "ERROR": "not_logged_in", "MESSAGE": "Authentication required" });
}
// Parse params
limit = val(url.limit ?: 20);
offset = val(url.offset ?: 0);
if (limit < 1) limit = 20;
if (limit > 100) limit = 100;
if (offset < 0) offset = 0;
try {
// Get orders for this user (exclude carts - status 0)
qOrders = queryExecute("
SELECT
o.OrderID,
o.OrderUUID,
o.OrderBusinessID,
o.OrderStatusID,
o.OrderTypeID,
o.OrderAddedOn,
o.OrderLastEditedOn,
b.BusinessName,
COALESCE(ot.tt_OrderTypeName, 'Unknown') as OrderTypeName
FROM Orders o
LEFT JOIN Businesses b ON b.BusinessID = o.OrderBusinessID
LEFT JOIN tt_OrderTypes ot ON ot.tt_OrderTypeID = o.OrderTypeID
WHERE o.OrderUserID = :userId
AND o.OrderStatusID > 0
ORDER BY o.OrderAddedOn DESC
LIMIT :limit OFFSET :offset
", {
userId: { value = userId, cfsqltype = "cf_sql_integer" },
limit: { value = limit, cfsqltype = "cf_sql_integer" },
offset: { value = offset, cfsqltype = "cf_sql_integer" }
});
// Get total count
qCount = queryExecute("
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("
SELECT
COUNT(*) as ItemCount,
SUM(OrderLineItemQuantity * OrderLineItemPrice) as Subtotal
FROM OrderLineItems
WHERE OrderLineItemOrderID = :orderId
AND OrderLineItemParentOrderLineItemID = 0
AND OrderLineItemIsDeleted = b'0'
", { orderId: { value = row.OrderID, cfsqltype = "cf_sql_integer" } });
itemCount = qItems.ItemCount ?: 0;
subtotal = qItems.Subtotal ?: 0;
tax = subtotal * 0.0875;
total = subtotal + tax;
// Get status text
statusText = "";
switch (row.OrderStatusID) {
case 1: statusText = "Submitted"; break;
case 2: statusText = "In Progress"; break;
case 3: statusText = "Ready"; break;
case 4: statusText = "Completed"; break;
case 5: statusText = "Cancelled"; break;
default: statusText = "Unknown";
}
arrayAppend(orders, {
"OrderID": row.OrderID,
"OrderUUID": row.OrderUUID ?: "",
"BusinessID": row.OrderBusinessID,
"BusinessName": row.BusinessName ?: "Unknown",
"OrderTotal": round(total * 100) / 100,
"OrderStatusID": row.OrderStatusID,
"StatusName": statusText,
"OrderTypeID": row.OrderTypeID ?: 0,
"TypeName": row.OrderTypeName,
"ItemCount": itemCount,
"CreatedAt": dateTimeFormat(row.OrderAddedOn, "yyyy-mm-dd'T'HH:nn:ss"),
"CompletedAt": (row.OrderStatusID >= 4 && len(row.OrderLastEditedOn))
? dateTimeFormat(row.OrderLastEditedOn, "yyyy-mm-dd'T'HH:nn:ss")
: ""
});
}
writeOutput(serializeJSON({
"OK": true,
"ORDERS": orders,
"TOTAL_COUNT": qCount.TotalCount
}));
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Failed to load order history",
"DETAIL": e.message
});
}
</cfscript>

View file

@ -271,6 +271,46 @@
<cfelse> <cfelse>
<cfset Quantity = 1> <cfset Quantity = 1>
</cfif> </cfif>
<!--- Check if this is an exclusive selection group (max = 1) --->
<!--- If so, deselect siblings when selecting a new item --->
<cfif IsSelected>
<!--- Get the parent line item's ItemID to check maxSel --->
<cfset qParentLI = queryExecute(
"SELECT OrderLineItemItemID FROM OrderLineItems WHERE OrderLineItemID = ? LIMIT 1",
[ { value = ParentLineItemID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qParentLI.recordCount GT 0>
<!--- Check if this parent item has maxSel = 1 (exclusive selection) --->
<cfset qParentItem = queryExecute(
"SELECT ItemMaxNumSelectionReq FROM Items WHERE ItemID = ? LIMIT 1",
[ { value = qParentLI.OrderLineItemItemID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qParentItem.recordCount GT 0 AND val(qParentItem.ItemMaxNumSelectionReq) EQ 1>
<!--- Exclusive selection: deselect all other siblings under this parent --->
<cfset queryExecute(
"
UPDATE OrderLineItems
SET OrderLineItemIsDeleted = b'1'
WHERE OrderLineItemOrderID = ?
AND OrderLineItemParentOrderLineItemID = ?
AND OrderLineItemItemID != ?
AND OrderLineItemIsDeleted = b'0'
",
[
{ value = OrderID, cfsqltype = "cf_sql_integer" },
{ value = ParentLineItemID, cfsqltype = "cf_sql_integer" },
{ value = ItemID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
</cfif>
</cfif>
</cfif>
</cfif> </cfif>
<!--- Find existing line item (by order, parent LI, item) ---> <!--- Find existing line item (by order, parent LI, item) --->

235
api/orders/setOrderType.cfm Normal file
View file

@ -0,0 +1,235 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cffunction name="readJsonBody" access="public" returntype="struct" output="false">
<cfset var raw = getHttpRequestData().content>
<cfif isNull(raw) OR len(trim(raw)) EQ 0>
<cfreturn {}>
</cfif>
<cftry>
<cfset var data = deserializeJSON(raw)>
<cfif isStruct(data)>
<cfreturn data>
<cfelse>
<cfreturn {}>
</cfif>
<cfcatch>
<cfreturn {}>
</cfcatch>
</cftry>
</cffunction>
<cffunction name="apiAbort" access="public" returntype="void" output="true">
<cfargument name="payload" type="struct" required="true">
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#serializeJSON(arguments.payload)#</cfoutput>
<cfabort>
</cffunction>
<cffunction name="loadCartPayload" access="public" returntype="struct" output="false">
<cfargument name="OrderID" type="numeric" required="true">
<cfset var out = {}>
<cfset var qOrder = queryExecute(
"
SELECT
OrderID,
OrderUUID,
OrderUserID,
OrderBusinessID,
OrderBusinessDeliveryMultiplier,
OrderTypeID,
OrderDeliveryFee,
OrderStatusID,
OrderAddressID,
OrderPaymentID,
OrderRemarks,
OrderAddedOn,
OrderLastEditedOn,
OrderSubmittedOn,
OrderServicePointID
FROM Orders
WHERE OrderID = ?
LIMIT 1
",
[ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qOrder.recordCount EQ 0>
<cfreturn { "OK": false, "ERROR": "not_found", "MESSAGE": "Order not found", "DETAIL": "" }>
</cfif>
<!--- Get business delivery fee for display in cart --->
<cfset var qBusiness = queryExecute(
"SELECT BusinessDeliveryFlatFee FROM Businesses WHERE BusinessID = ? LIMIT 1",
[ { value = qOrder.OrderBusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset var businessDeliveryFee = qBusiness.recordCount GT 0 ? qBusiness.BusinessDeliveryFlatFee : 0>
<cfset out.Order = {
"OrderID": qOrder.OrderID,
"OrderUUID": qOrder.OrderUUID,
"OrderUserID": qOrder.OrderUserID,
"OrderBusinessID": qOrder.OrderBusinessID,
"OrderBusinessDeliveryMultiplier": qOrder.OrderBusinessDeliveryMultiplier,
"OrderTypeID": qOrder.OrderTypeID,
"OrderDeliveryFee": qOrder.OrderDeliveryFee,
"BusinessDeliveryFee": businessDeliveryFee,
"OrderStatusID": qOrder.OrderStatusID,
"OrderAddressID": qOrder.OrderAddressID,
"OrderPaymentID": qOrder.OrderPaymentID,
"OrderRemarks": qOrder.OrderRemarks,
"OrderAddedOn": qOrder.OrderAddedOn,
"OrderLastEditedOn": qOrder.OrderLastEditedOn,
"OrderSubmittedOn": qOrder.OrderSubmittedOn,
"OrderServicePointID": qOrder.OrderServicePointID
}>
<cfset var qLI = queryExecute(
"
SELECT
OrderLineItemID,
OrderLineItemParentOrderLineItemID,
OrderLineItemOrderID,
OrderLineItemItemID,
OrderLineItemStatusID,
OrderLineItemPrice,
OrderLineItemQuantity,
OrderLineItemRemark,
OrderLineItemIsDeleted,
OrderLineItemAddedOn
FROM OrderLineItems
WHERE OrderLineItemOrderID = ?
ORDER BY OrderLineItemID
",
[ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset var rows = []>
<cfloop query="qLI">
<cfset arrayAppend(rows, {
"OrderLineItemID": qLI.OrderLineItemID,
"OrderLineItemParentOrderLineItemID": qLI.OrderLineItemParentOrderLineItemID,
"OrderLineItemOrderID": qLI.OrderLineItemOrderID,
"OrderLineItemItemID": qLI.OrderLineItemItemID,
"OrderLineItemStatusID": qLI.OrderLineItemStatusID,
"OrderLineItemPrice": qLI.OrderLineItemPrice,
"OrderLineItemQuantity": qLI.OrderLineItemQuantity,
"OrderLineItemRemark": qLI.OrderLineItemRemark,
"OrderLineItemIsDeleted": qLI.OrderLineItemIsDeleted,
"OrderLineItemAddedOn": qLI.OrderLineItemAddedOn
})>
</cfloop>
<cfset out.OrderLineItems = rows>
<cfset out.OK = true>
<cfset out.ERROR = "">
<cfreturn out>
</cffunction>
<cfset data = readJsonBody()>
<cfset OrderID = val( structKeyExists(data,"OrderID") ? data.OrderID : 0 )>
<cfset OrderTypeID = val( structKeyExists(data,"OrderTypeID") ? data.OrderTypeID : 0 )>
<cfset AddressID = structKeyExists(data,"AddressID") ? val(data.AddressID) : 0>
<!--- Validate required params --->
<cfif OrderID LTE 0>
<cfset apiAbort({
"OK": false,
"ERROR": "missing_params",
"MESSAGE": "OrderID is required.",
"DETAIL": ""
})>
</cfif>
<!--- OrderTypeID: 2=takeaway, 3=delivery --->
<cfif OrderTypeID LT 2 OR OrderTypeID GT 3>
<cfset apiAbort({
"OK": false,
"ERROR": "invalid_order_type",
"MESSAGE": "OrderTypeID must be 2 (takeaway) or 3 (delivery).",
"DETAIL": ""
})>
</cfif>
<!--- Delivery requires address --->
<cfif OrderTypeID EQ 3 AND AddressID LTE 0>
<cfset apiAbort({
"OK": false,
"ERROR": "missing_address",
"MESSAGE": "Delivery orders require an AddressID.",
"DETAIL": ""
})>
</cfif>
<cftry>
<!--- Verify order exists and is still a cart (status 0) --->
<cfset qOrder = queryExecute(
"
SELECT OrderID, OrderBusinessID, OrderStatusID
FROM Orders
WHERE OrderID = ?
LIMIT 1
",
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qOrder.recordCount EQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Order not found.", "DETAIL": "" })>
</cfif>
<cfif qOrder.OrderStatusID NEQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "invalid_status", "MESSAGE": "Order type can only be changed while in cart status.", "DETAIL": "" })>
</cfif>
<!--- Get delivery fee from business if delivery --->
<cfset deliveryFee = 0>
<cfif OrderTypeID EQ 3>
<cfset qBiz = queryExecute(
"SELECT BusinessDeliveryFlatFee FROM Businesses WHERE BusinessID = ? LIMIT 1",
[ { value = qOrder.OrderBusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qBiz.recordCount GT 0>
<cfset deliveryFee = qBiz.BusinessDeliveryFlatFee>
</cfif>
</cfif>
<!--- Update order type and address --->
<cfset queryExecute(
"
UPDATE Orders
SET OrderTypeID = ?,
OrderAddressID = ?,
OrderDeliveryFee = ?,
OrderLastEditedOn = ?
WHERE OrderID = ?
",
[
{ value = OrderTypeID, cfsqltype = "cf_sql_integer" },
{ value = (OrderTypeID EQ 3 ? AddressID : javaCast("null", "")), cfsqltype = "cf_sql_integer", null = (OrderTypeID NEQ 3) },
{ value = deliveryFee, cfsqltype = "cf_sql_decimal" },
{ value = now(), cfsqltype = "cf_sql_timestamp" },
{ value = OrderID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
<!--- Return updated cart --->
<cfset payload = loadCartPayload(OrderID)>
<cfset apiAbort(payload)>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "DB error updating order type",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>

View file

@ -159,8 +159,21 @@
<cfset apiAbort({ "OK": false, "ERROR": "bad_state", "MESSAGE": "Order is not in cart state.", "DETAIL": "" })> <cfset apiAbort({ "OK": false, "ERROR": "bad_state", "MESSAGE": "Order is not in cart state.", "DETAIL": "" })>
</cfif> </cfif>
<cfif qOrder.OrderTypeID NEQ 1> <!--- OrderTypeID: 1=dine-in, 2=takeaway, 3=delivery (0=undecided is invalid for submit) --->
<cfset apiAbort({ "OK": false, "ERROR": "bad_type", "MESSAGE": "Only dine-in orders (OrderTypeID=1) are supported in this MVP submit endpoint.", "DETAIL": "" })> <cfif qOrder.OrderTypeID LT 1 OR qOrder.OrderTypeID GT 3>
<cfset apiAbort({ "OK": false, "ERROR": "bad_type", "MESSAGE": "Order type must be set before submitting (1=dine-in, 2=takeaway, 3=delivery).", "DETAIL": "" })>
</cfif>
<!--- Delivery orders require an address --->
<cfif qOrder.OrderTypeID EQ 3>
<cfset qAddr = queryExecute(
"SELECT OrderAddressID FROM Orders WHERE OrderID = ? LIMIT 1",
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qAddr.OrderAddressID LTE 0 OR isNull(qAddr.OrderAddressID)>
<cfset apiAbort({ "OK": false, "ERROR": "missing_address", "MESSAGE": "Delivery orders require a delivery address.", "DETAIL": "" })>
</cfif>
</cfif> </cfif>
<!--- Must have at least one non-deleted root line item ---> <!--- Must have at least one non-deleted root line item --->

149
privacy.html Normal file
View file

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payfrit - Privacy Policy</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1 {
color: #222;
border-bottom: 2px solid #eee;
padding-bottom: 10px;
}
h2 {
color: #444;
margin-top: 30px;
}
h3 {
color: #555;
margin-top: 20px;
}
p, li {
color: #555;
}
ul {
padding-left: 20px;
}
li {
margin-bottom: 8px;
}
.last-updated {
color: #888;
font-size: 0.9em;
}
a {
color: #007AFF;
}
</style>
</head>
<body>
<h1>Privacy Policy</h1>
<p class="last-updated">Last updated: January 2026</p>
<p>Payfrit ("we," "our," or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our mobile application and related services.</p>
<h2>Information We Collect</h2>
<h3>Account Information</h3>
<p>When you create an account, we collect:</p>
<ul>
<li>Email address</li>
<li>Mobile phone number</li>
<li>Username</li>
<li>Password (stored in encrypted form)</li>
</ul>
<h3>Location and Beacon Data</h3>
<p>Our app uses Bluetooth Low Energy (BLE) beacon technology to provide location-based services within participating venues. We collect:</p>
<ul>
<li>Proximity to beacons within participating restaurants and venues</li>
<li>Duration of time spent near specific beacons (dwell time)</li>
<li>General location data to identify which venue you are visiting</li>
</ul>
<p>This data is used to enable mobile ordering, verify task completion for workers, and improve your experience at participating locations. Location data is only collected when the app is in use and you have granted location permissions.</p>
<h3>Transaction Information</h3>
<p>When you place orders or complete transactions, we collect:</p>
<ul>
<li>Order details and history</li>
<li>Payment method information (processed securely through third-party payment processors)</li>
<li>Transaction timestamps</li>
</ul>
<h3>Device Information</h3>
<p>We automatically collect certain device information including:</p>
<ul>
<li>Device type and operating system</li>
<li>Unique device identifiers</li>
<li>App version</li>
<li>Browser type (when accessing web features)</li>
<li>IP address</li>
</ul>
<h2>How We Use Your Information</h2>
<p>We use the information we collect to:</p>
<ul>
<li>Provide and maintain our services</li>
<li>Process orders and transactions</li>
<li>Verify your location within participating venues</li>
<li>Verify task completion for gig workers</li>
<li>Send you order confirmations and updates</li>
<li>Communicate with you about our services</li>
<li>Improve and personalize your experience</li>
<li>Ensure the security and integrity of our platform</li>
</ul>
<h2>Information Sharing</h2>
<p>We do not sell, rent, or lease your personal information to third parties.</p>
<p>We may share your information with:</p>
<ul>
<li><strong>Participating venues:</strong> Order details necessary to fulfill your requests</li>
<li><strong>Payment processors:</strong> Information required to process transactions securely</li>
<li><strong>Service providers:</strong> Third parties who assist us in operating our platform, subject to confidentiality agreements</li>
<li><strong>Legal requirements:</strong> When required by law or to protect our rights and safety</li>
</ul>
<h2>Data Security</h2>
<p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the internet or electronic storage is 100% secure.</p>
<h2>Your Rights and Choices</h2>
<h3>Access and Update</h3>
<p>You can access and update your account information at any time through the app settings.</p>
<h3>Location Permissions</h3>
<p>You can enable or disable location services and Bluetooth permissions through your device settings. Note that disabling these permissions may limit certain features of the app.</p>
<h3>Communications</h3>
<p>You may opt out of promotional communications by following the unsubscribe instructions in any email or by contacting us directly. You will still receive transactional messages related to your orders and account.</p>
<h3>Account Deletion</h3>
<p>You may request deletion of your account and associated personal data by contacting us at privacy@payfrit.com. We will process your request within 30 days, subject to any legal obligations to retain certain information.</p>
<h2>Data Retention</h2>
<p>We retain your personal information for as long as your account is active or as needed to provide you services. We may retain certain information as required by law or for legitimate business purposes.</p>
<h2>Children's Privacy</h2>
<p>Our services are not intended for users under the age of 13. We do not knowingly collect personal information from children under 13. If we become aware that we have collected such information, we will take steps to delete it.</p>
<h2>Changes to This Policy</h2>
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new policy on this page and updating the "Last updated" date. Your continued use of the app after any changes constitutes acceptance of the updated policy.</p>
<h2>Contact Us</h2>
<p>If you have any questions about this Privacy Policy or our data practices, please contact us at:</p>
<p>
Email: <a href="mailto:privacy@payfrit.com">privacy@payfrit.com</a><br>
Payfrit<br>
Santa Monica, CA
</p>
</body>
</html>