From d8d7efe0561a7976da48efbda030ebae043c92e2 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Thu, 8 Jan 2026 20:01:07 -0800 Subject: [PATCH] Add user account APIs and fix Lucee header handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/Application.cfm | 34 ++++- api/addresses/add.cfm | 147 +++++++++++++++++++ api/addresses/list.cfm | 73 ++++++++++ api/auth/avatar.cfm | 182 ++++++++++++++++++++++++ api/auth/profile.cfm | 171 ++++++++++++++++++++++ api/debug/checkToken.cfm | 73 ++++++++++ api/orders/getCart.cfm | 9 ++ api/orders/getOrCreateCart.cfm | 75 ++++++++-- api/orders/getPendingForUser.cfm | 109 ++++++++++++++ api/orders/history.cfm | 160 +++++++++++++++++++++ api/orders/setLineItem.cfm | 40 ++++++ api/orders/setOrderType.cfm | 235 +++++++++++++++++++++++++++++++ api/orders/submit.cfm | 17 ++- privacy.html | 149 ++++++++++++++++++++ 14 files changed, 1458 insertions(+), 16 deletions(-) create mode 100644 api/addresses/add.cfm create mode 100644 api/addresses/list.cfm create mode 100644 api/auth/avatar.cfm create mode 100644 api/auth/profile.cfm create mode 100644 api/debug/checkToken.cfm create mode 100644 api/orders/getPendingForUser.cfm create mode 100644 api/orders/history.cfm create mode 100644 api/orders/setOrderType.cfm create mode 100644 privacy.html diff --git a/api/Application.cfm b/api/Application.cfm index 1b1410f..cd4649a 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -42,8 +42,16 @@ function apiAbort(payload) { } function headerValue(name) { - k = "HTTP_" & ucase(reReplace(arguments.name, "[^A-Za-z0-9]", "_", "all")); - if (structKeyExists(cgi, k)) return trim(cgi[k]); + // 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 ""; } @@ -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/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/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/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 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/updateStations.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) 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; @@ -184,7 +203,16 @@ if (len(request._api_hdrBiz) && isNumeric(request._api_hdrBiz)) { // Enforce auth (except public) if (!request._api_isPublic) { 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) { apiAbort({ "OK": false, "ERROR": "no_business_selected" }); diff --git a/api/addresses/add.cfm b/api/addresses/add.cfm new file mode 100644 index 0000000..6aa147c --- /dev/null +++ b/api/addresses/add.cfm @@ -0,0 +1,147 @@ + + + + + + +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 + })); +} + diff --git a/api/addresses/list.cfm b/api/addresses/list.cfm new file mode 100644 index 0000000..69f67a8 --- /dev/null +++ b/api/addresses/list.cfm @@ -0,0 +1,73 @@ + + + + + + +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 + })); +} + diff --git a/api/auth/avatar.cfm b/api/auth/avatar.cfm new file mode 100644 index 0000000..c633943 --- /dev/null +++ b/api/auth/avatar.cfm @@ -0,0 +1,182 @@ + + + + + + + + +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" }); + diff --git a/api/auth/profile.cfm b/api/auth/profile.cfm new file mode 100644 index 0000000..2c9a6b8 --- /dev/null +++ b/api/auth/profile.cfm @@ -0,0 +1,171 @@ + + + + + + +/** + * 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" }); + diff --git a/api/debug/checkToken.cfm b/api/debug/checkToken.cfm new file mode 100644 index 0000000..8531314 --- /dev/null +++ b/api/debug/checkToken.cfm @@ -0,0 +1,73 @@ + + + + + +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)); + diff --git a/api/orders/getCart.cfm b/api/orders/getCart.cfm index 43c92b3..41d31c7 100644 --- a/api/orders/getCart.cfm +++ b/api/orders/getCart.cfm @@ -64,6 +64,14 @@ + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + @@ -183,10 +235,11 @@ - - + + + + + + + +/** + * 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)); + diff --git a/api/orders/history.cfm b/api/orders/history.cfm new file mode 100644 index 0000000..a1a3aa6 --- /dev/null +++ b/api/orders/history.cfm @@ -0,0 +1,160 @@ + + + + + + +/** + * 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 + }); +} + diff --git a/api/orders/setLineItem.cfm b/api/orders/setLineItem.cfm index 1d0d487..108da40 100644 --- a/api/orders/setLineItem.cfm +++ b/api/orders/setLineItem.cfm @@ -271,6 +271,46 @@ + + + + + + + + + + + + + + + + + diff --git a/api/orders/setOrderType.cfm b/api/orders/setOrderType.cfm new file mode 100644 index 0000000..823d20f --- /dev/null +++ b/api/orders/setOrderType.cfm @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + #serializeJSON(arguments.payload)# + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/orders/submit.cfm b/api/orders/submit.cfm index 51b4d1e..1d52472 100644 --- a/api/orders/submit.cfm +++ b/api/orders/submit.cfm @@ -159,8 +159,21 @@ - - + + + + + + + + + + + diff --git a/privacy.html b/privacy.html new file mode 100644 index 0000000..a79dc8e --- /dev/null +++ b/privacy.html @@ -0,0 +1,149 @@ + + + + + + Payfrit - Privacy Policy + + + +

Privacy Policy

+

Last updated: January 2026

+ +

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.

+ +

Information We Collect

+ +

Account Information

+

When you create an account, we collect:

+
    +
  • Email address
  • +
  • Mobile phone number
  • +
  • Username
  • +
  • Password (stored in encrypted form)
  • +
+ +

Location and Beacon Data

+

Our app uses Bluetooth Low Energy (BLE) beacon technology to provide location-based services within participating venues. We collect:

+
    +
  • Proximity to beacons within participating restaurants and venues
  • +
  • Duration of time spent near specific beacons (dwell time)
  • +
  • General location data to identify which venue you are visiting
  • +
+

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.

+ +

Transaction Information

+

When you place orders or complete transactions, we collect:

+
    +
  • Order details and history
  • +
  • Payment method information (processed securely through third-party payment processors)
  • +
  • Transaction timestamps
  • +
+ +

Device Information

+

We automatically collect certain device information including:

+
    +
  • Device type and operating system
  • +
  • Unique device identifiers
  • +
  • App version
  • +
  • Browser type (when accessing web features)
  • +
  • IP address
  • +
+ +

How We Use Your Information

+

We use the information we collect to:

+
    +
  • Provide and maintain our services
  • +
  • Process orders and transactions
  • +
  • Verify your location within participating venues
  • +
  • Verify task completion for gig workers
  • +
  • Send you order confirmations and updates
  • +
  • Communicate with you about our services
  • +
  • Improve and personalize your experience
  • +
  • Ensure the security and integrity of our platform
  • +
+ +

Information Sharing

+

We do not sell, rent, or lease your personal information to third parties.

+

We may share your information with:

+
    +
  • Participating venues: Order details necessary to fulfill your requests
  • +
  • Payment processors: Information required to process transactions securely
  • +
  • Service providers: Third parties who assist us in operating our platform, subject to confidentiality agreements
  • +
  • Legal requirements: When required by law or to protect our rights and safety
  • +
+ +

Data Security

+

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.

+ +

Your Rights and Choices

+ +

Access and Update

+

You can access and update your account information at any time through the app settings.

+ +

Location Permissions

+

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.

+ +

Communications

+

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.

+ +

Account Deletion

+

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.

+ +

Data Retention

+

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.

+ +

Children's Privacy

+

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.

+ +

Changes to This Policy

+

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.

+ +

Contact Us

+

If you have any questions about this Privacy Policy or our data practices, please contact us at:

+

+ Email: privacy@payfrit.com
+ Payfrit
+ Santa Monica, CA +

+ + +