Add team endpoint and chat features for portal

- Add /api/portal/team.cfm for employee listing
- Add chat endpoints (getMessages, sendMessage, markRead, getActiveChat)
- Add OTP authentication endpoints
- Add address management endpoints (delete, setDefault, states)
- Add task completion and chat task endpoints
- Update Application.cfm allowlist

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-11 17:03:55 -08:00
parent d8d7efe056
commit 8092384702
35 changed files with 2219 additions and 38 deletions

View file

@ -2,7 +2,7 @@
<cfsetting enablecfoutputonly="true">
<!---
Payfrit API Application.cfm
Payfrit API Application.cfm (updated)
FIX: Provide a DEFAULT datasource so endpoints can call queryExecute()
without specifying { datasource="payfrit" } every time.
@ -32,6 +32,11 @@
showdebugoutput="false"
>
<!--- Initialize Twilio for SMS --->
<cfif NOT structKeyExists(application, "twilioObj")>
<cfset application.twilioObj = new library.cfc.twilio() />
</cfif>
<!--- Stripe Configuration (loads from config/stripe.cfm) --->
<cfinclude template="config/stripe.cfm">
@ -73,6 +78,11 @@ 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/auth/sendOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/verifyOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/loginOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/verifyLoginOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/completeProfile.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;
@ -80,6 +90,8 @@ if (len(request._api_path)) {
if (findNoCase("/api/beacons/list_all.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/getBusinessFromBeacon.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/items.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/addresses/states.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/debug/", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/orders/getOrCreateCart.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/orders/getCart.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/orders/setLineItem.cfm", request._api_path)) request._api_isPublic = true;
@ -89,11 +101,25 @@ if (len(request._api_path)) {
if (findNoCase("/api/orders/checkStatusUpdate.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/orders/getDetail.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/users/search.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/listPending.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/accept.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/listMine.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/complete.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/completeChat.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/getDetails.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/callserver", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/tasks/createChat.cfm", request._api_path)) request._api_isPublic = true;
// Chat endpoints
if (findNoCase("/api/chat/getMessages.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/chat/sendMessage.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/chat/markRead.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/chat/getActiveChat.cfm", request._api_path)) request._api_isPublic = true;
// Token validation (for WebSocket server)
if (findNoCase("/api/auth/validateToken.cfm", request._api_path)) request._api_isPublic = true;
// Worker app endpoints
if (findNoCase("/api/workers/myBusinesses.cfm", request._api_path)) request._api_isPublic = true;
@ -101,6 +127,7 @@ if (len(request._api_path)) {
// Portal endpoints
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/team.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;
@ -121,6 +148,7 @@ if (len(request._api_path)) {
// 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;
if (findNoCase("/api/admin/testTaskType.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/testTaskInsert.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugBusinesses.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/setupStations.cfm", request._api_path)) request._api_isPublic = true;
@ -149,6 +177,11 @@ if (len(request._api_path)) {
if (findNoCase("/api/admin/copyDrinksToBigDeans.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugDrinkStructure.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/addDrinkModifiers.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/add_task_columns.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/add_service_category.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/createChatMessagesTable.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/addTaskSourceColumns.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugChatMessages.cfm", request._api_path)) request._api_isPublic = true;
// Setup/Import endpoints
if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true;

View file

@ -113,7 +113,7 @@ try {
}, { datasource: "payfrit" });
// Get state info for response
qState = queryExecute("SELECT StateAbbreviation, StateName FROM States WHERE StateID = :stateId", {
qState = queryExecute("SELECT tt_StateAbbreviation as StateAbbreviation, tt_StateName as StateName FROM tt_States WHERE tt_StateID = :stateId", {
stateId: { value: stateId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });

103
api/addresses/delete.cfm Normal file
View file

@ -0,0 +1,103 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<!--- Soft-delete a 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 {
userId = request.UserID ?: 0;
if (userId <= 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "unauthorized",
"MESSAGE": "Authentication required"
}));
abort;
}
data = readJsonBody();
addressId = val(data.AddressID ?: 0);
if (addressId <= 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "missing_field",
"MESSAGE": "AddressID is required"
}));
abort;
}
// Verify address belongs to user
qCheck = queryExecute("
SELECT AddressID, AddressIsDefaultDelivery
FROM Addresses
WHERE AddressID = :addressId
AND AddressUserID = :userId
AND AddressIsDeleted = 0
", {
addressId: { value: addressId, cfsqltype: "cf_sql_integer" },
userId: { value: userId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "not_found",
"MESSAGE": "Address not found"
}));
abort;
}
wasDefault = qCheck.AddressIsDefaultDelivery == 1;
// Soft delete the address
queryExecute("
UPDATE Addresses
SET AddressIsDeleted = 1,
AddressIsDefaultDelivery = 0
WHERE AddressID = :addressId
", {
addressId: { value: addressId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
// If this was the default, set another one as default
if (wasDefault) {
queryExecute("
UPDATE Addresses
SET AddressIsDefaultDelivery = 1
WHERE AddressUserID = :userId
AND (AddressBusinessID = 0 OR AddressBusinessID IS NULL)
AND AddressTypeID LIKE '%2%'
AND AddressIsDeleted = 0
ORDER BY AddressID DESC
LIMIT 1
", {
userId: { value: userId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
}
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Address deleted"
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
}));
}
</cfscript>

View file

@ -18,25 +18,27 @@ try {
}
// Get user's delivery addresses (AddressTypeID contains "2" for delivery, BusinessID is 0 or NULL for personal)
// Use GROUP BY to return only distinct addresses based on content
qAddresses = queryExecute("
SELECT
a.AddressID,
MIN(a.AddressID) as AddressID,
a.AddressLabel,
a.AddressIsDefaultDelivery,
MAX(a.AddressIsDefaultDelivery) as AddressIsDefaultDelivery,
a.AddressLine1,
a.AddressLine2,
a.AddressCity,
a.AddressStateID,
s.StateAbbreviation,
s.StateName,
s.tt_StateAbbreviation as StateAbbreviation,
s.tt_StateName as StateName,
a.AddressZIPCode
FROM Addresses a
LEFT JOIN States s ON a.AddressStateID = s.StateID
LEFT JOIN tt_States s ON a.AddressStateID = s.tt_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
GROUP BY a.AddressLine1, COALESCE(a.AddressLine2, ''), a.AddressCity, a.AddressStateID, a.AddressZIPCode
ORDER BY AddressIsDefaultDelivery DESC, AddressID DESC
", {
userId: { value: userId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });

View file

@ -0,0 +1,95 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<!--- Set an address as the default delivery address --->
<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 {
userId = request.UserID ?: 0;
if (userId <= 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "unauthorized",
"MESSAGE": "Authentication required"
}));
abort;
}
data = readJsonBody();
addressId = val(data.AddressID ?: 0);
if (addressId <= 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "missing_field",
"MESSAGE": "AddressID is required"
}));
abort;
}
// Verify address belongs to user
qCheck = queryExecute("
SELECT AddressID
FROM Addresses
WHERE AddressID = :addressId
AND AddressUserID = :userId
AND AddressIsDeleted = 0
", {
addressId: { value: addressId, cfsqltype: "cf_sql_integer" },
userId: { value: userId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qCheck.recordCount == 0) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "not_found",
"MESSAGE": "Address not found"
}));
abort;
}
// Clear all defaults for this user
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" });
// Set this one as default
queryExecute("
UPDATE Addresses
SET AddressIsDefaultDelivery = 1
WHERE AddressID = :addressId
", {
addressId: { value: addressId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Default address updated"
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
}));
}
</cfscript>

35
api/addresses/states.cfm Normal file
View file

@ -0,0 +1,35 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<!--- List US states for address forms --->
<cfscript>
try {
qStates = queryExecute("
SELECT tt_StateID as StateID, tt_StateAbbreviation as StateAbbreviation, tt_StateName as StateName
FROM tt_States
ORDER BY tt_StateName
", {}, { datasource: "payfrit" });
states = [];
for (row in qStates) {
arrayAppend(states, {
"StateID": row.StateID,
"Abbr": row.StateAbbreviation,
"Name": row.StateName
});
}
writeOutput(serializeJSON({
"OK": true,
"STATES": states
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
}));
}
</cfscript>

View file

@ -0,0 +1,66 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Add TaskSourceType and TaskSourceID columns to Tasks table
// These are needed for chat persistence feature
result = { "OK": true, "STEPS": [] };
try {
// Check if columns already exist
cols = queryExecute("
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'Tasks'
AND COLUMN_NAME IN ('TaskSourceType', 'TaskSourceID')
", [], { datasource: "payfrit" });
existingCols = valueList(cols.COLUMN_NAME);
arrayAppend(result.STEPS, "Existing columns: #existingCols#");
// Add TaskSourceType if missing
if (!listFindNoCase(existingCols, "TaskSourceType")) {
queryExecute("
ALTER TABLE Tasks ADD COLUMN TaskSourceType VARCHAR(50) NULL
", [], { datasource: "payfrit" });
arrayAppend(result.STEPS, "Added TaskSourceType column");
} else {
arrayAppend(result.STEPS, "TaskSourceType already exists");
}
// Add TaskSourceID if missing
if (!listFindNoCase(existingCols, "TaskSourceID")) {
queryExecute("
ALTER TABLE Tasks ADD COLUMN TaskSourceID INT NULL
", [], { datasource: "payfrit" });
arrayAppend(result.STEPS, "Added TaskSourceID column");
} else {
arrayAppend(result.STEPS, "TaskSourceID already exists");
}
// Verify columns now exist
verifyQuery = queryExecute("
SELECT COLUMN_NAME, DATA_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'payfrit'
AND TABLE_NAME = 'Tasks'
AND COLUMN_NAME IN ('TaskSourceType', 'TaskSourceID')
", [], { datasource: "payfrit" });
result.COLUMNS = [];
for (row in verifyQuery) {
arrayAppend(result.COLUMNS, { "name": row.COLUMN_NAME, "type": row.DATA_TYPE });
}
arrayAppend(result.STEPS, "Migration complete");
} catch (any e) {
result.OK = false;
result.ERROR = e.message;
}
writeOutput(serializeJSON(result));
</cfscript>

View file

@ -0,0 +1,53 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8">
<cfscript>
try {
// Create ChatMessages table
queryExecute("
CREATE TABLE IF NOT EXISTS ChatMessages (
MessageID INT AUTO_INCREMENT PRIMARY KEY,
TaskID INT NOT NULL,
SenderUserID INT NOT NULL,
SenderType ENUM('customer', 'worker') NOT NULL,
MessageText TEXT NOT NULL,
IsRead TINYINT(1) DEFAULT 0,
CreatedOn DATETIME DEFAULT NOW(),
INDEX idx_task (TaskID),
INDEX idx_sender (SenderUserID),
INDEX idx_created (CreatedOn)
)
", {}, { datasource: "payfrit" });
// Also add a "Chat" category if it doesn't exist for business 17
existing = queryExecute("
SELECT TaskCategoryID FROM TaskCategories
WHERE TaskCategoryBusinessID = 17 AND TaskCategoryName = 'Chat'
", {}, { datasource: "payfrit" });
if (existing.recordCount == 0) {
queryExecute("
INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor)
VALUES (17, 'Chat', '##2196F3')
", {}, { datasource: "payfrit" });
}
// Verify table was created
cols = queryExecute("DESCRIBE ChatMessages", {}, { datasource: "payfrit" });
colNames = [];
for (c in cols) {
arrayAppend(colNames, c.Field);
}
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "ChatMessages table created successfully",
"COLUMNS": colNames
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -0,0 +1,41 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Debug: check ChatMessages table contents
try {
qAll = queryExecute("SELECT * FROM ChatMessages ORDER BY CreatedOn DESC LIMIT 50", [], { datasource: "payfrit" });
messages = [];
for (row in qAll) {
arrayAppend(messages, {
"MessageID": row.MessageID,
"TaskID": row.TaskID,
"SenderUserID": row.SenderUserID,
"SenderType": row.SenderType,
"MessageText": left(row.MessageText, 100),
"CreatedOn": row.CreatedOn
});
}
// Also check schema
schema = queryExecute("DESCRIBE ChatMessages", [], { datasource: "payfrit" });
cols = [];
for (col in schema) {
arrayAppend(cols, { "Field": col.Field, "Type": col.Type });
}
writeOutput(serializeJSON({
"OK": true,
"TOTAL_MESSAGES": qAll.recordCount,
"MESSAGES": messages,
"SCHEMA": cols
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -0,0 +1,17 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
qTask = queryExecute("
SELECT TaskID, TaskTypeID, TaskClaimedByUserID, TaskCompletedOn
FROM Tasks
WHERE TaskID = 57
", [], { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"TaskID": qTask.TaskID,
"TaskTypeID": qTask.TaskTypeID,
"TaskClaimedByUserID": qTask.TaskClaimedByUserID,
"TaskCompletedOn": qTask.TaskCompletedOn
}));
</cfscript>

View file

@ -0,0 +1,143 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Complete user profile after phone verification
*
* POST: {
* "firstName": "John",
* "lastName": "Smith",
* "email": "john@example.com"
* }
*
* Requires auth token in header: X-User-Token: <token>
* (Parsed by Application.cfm into request.UserID)
*
* Returns: { OK: true } and sends confirmation email
*/
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
function getAuthUserId() {
// Use request.UserID set by Application.cfm from X-User-Token header
if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.UserID > 0) {
return request.UserID;
}
return 0;
}
function isValidEmail(required string email) {
return reFind("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", arguments.email) > 0;
}
try {
userId = getAuthUserId();
if (userId == 0) {
apiAbort({ "OK": false, "ERROR": "unauthorized", "MESSAGE": "Authentication required" });
}
data = readJsonBody();
firstName = structKeyExists(data, "firstName") ? trim(data.firstName) : "";
lastName = structKeyExists(data, "lastName") ? trim(data.lastName) : "";
email = structKeyExists(data, "email") ? trim(lCase(data.email)) : "";
// Validate required fields
if (!len(firstName)) {
apiAbort({ "OK": false, "ERROR": "missing_first_name", "MESSAGE": "First name is required" });
}
if (!len(lastName)) {
apiAbort({ "OK": false, "ERROR": "missing_last_name", "MESSAGE": "Last name is required" });
}
if (!len(email) || !isValidEmail(email)) {
apiAbort({ "OK": false, "ERROR": "invalid_email", "MESSAGE": "Please enter a valid email address" });
}
// Check if email is already used by another verified account
qEmailCheck = queryExecute("
SELECT UserID FROM Users
WHERE UserEmailAddress = :email
AND UserIsEmailVerified = 1
AND UserID != :userId
LIMIT 1
", {
email: { value: email, cfsqltype: "cf_sql_varchar" },
userId: { value: userId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qEmailCheck.recordCount > 0) {
apiAbort({ "OK": false, "ERROR": "email_exists", "MESSAGE": "This email is already associated with another account" });
}
// Get current user UUID for email confirmation link
qUser = queryExecute("
SELECT UserUUID FROM Users WHERE UserID = :userId
", { userId: { value: userId, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
// Update user profile AND mark account as verified/active
// This completes the signup process
queryExecute("
UPDATE Users
SET UserFirstName = :firstName,
UserLastName = :lastName,
UserEmailAddress = :email,
UserIsEmailVerified = 0,
UserIsContactVerified = 1,
UserIsActive = 1
WHERE UserID = :userId
", {
firstName: { value: firstName, cfsqltype: "cf_sql_varchar" },
lastName: { value: lastName, cfsqltype: "cf_sql_varchar" },
email: { value: email, cfsqltype: "cf_sql_varchar" },
userId: { value: userId, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
// Send confirmation email
confirmLink = "https://biz.payfrit.com/confirm_email.cfm?UUID=" & qUser.UserUUID;
emailBody = "
<p>Welcome to Payfrit, #firstName#!</p>
<p>Please click the link below to confirm your email address:</p>
<p><a href='#confirmLink#'>Confirm Email</a></p>
<p>Or copy and paste this URL into your browser:</p>
<p>#confirmLink#</p>
<p>Thanks,<br>The Payfrit Team</p>
";
</cfscript>
<cfmail to="#email#"
from="admin@payfrit.com"
subject="Welcome to Payfrit - Please confirm your email"
type="html">
<cfoutput>#emailBody#</cfoutput>
</cfmail>
<cfscript>
writeOutput(serializeJSON({
"OK": true,
"MESSAGE": "Profile updated. Please check your email to confirm your address."
}));
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

97
api/auth/loginOTP.cfm Normal file
View file

@ -0,0 +1,97 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Send OTP to phone number for LOGIN (existing verified accounts)
*
* POST: { "phone": "5551234567" }
*
* Returns: { OK: true, UUID: "..." } or { OK: false, ERROR: "..." }
*
* Only works for verified accounts. Returns error if no account found.
*/
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
function normalizePhone(required string p) {
var x = trim(arguments.p);
x = reReplace(x, "[^0-9]", "", "all");
if (len(x) == 11 && left(x, 1) == "1") {
x = right(x, 10);
}
return x;
}
function generateOTP() {
return randRange(100000, 999999);
}
try {
data = readJsonBody();
phone = structKeyExists(data, "phone") ? normalizePhone(data.phone) : "";
if (len(phone) != 10) {
apiAbort({ "OK": false, "ERROR": "invalid_phone", "MESSAGE": "Please enter a valid 10-digit phone number" });
}
// Find verified account with this phone
qUser = queryExecute("
SELECT UserID, UserUUID
FROM Users
WHERE UserContactNumber = :phone
AND UserIsContactVerified = 1
LIMIT 1
", { phone: { value: phone, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" });
if (qUser.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "no_account", "MESSAGE": "No account found with this phone number. Please sign up first." });
}
// Generate and save OTP
otp = generateOTP();
queryExecute("
UPDATE Users
SET UserMobileVerifyCode = :otp
WHERE UserID = :userId
", {
otp: { value: otp, cfsqltype: "cf_sql_varchar" },
userId: { value: qUser.UserID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
// Send OTP via Twilio
smsResult = application.twilioObj.sendSMS(
recipientNumber: "+1" & phone,
messageBody: "Your Payfrit login code is: " & otp
);
writeOutput(serializeJSON({
"OK": true,
"UUID": qUser.UserUUID,
"MESSAGE": smsResult.success ? "Login code sent" : "SMS failed - please try again"
}));
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

155
api/auth/sendOTP.cfm Normal file
View file

@ -0,0 +1,155 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Send OTP to phone number for signup
*
* POST: { "phone": "5551234567" }
*
* Returns: { OK: true, UUID: "..." } or { OK: false, ERROR: "..." }
*
* If phone already has verified account, returns error.
* If phone has unverified account, resends OTP.
* Otherwise creates new user record with OTP.
*/
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
function normalizePhone(required string p) {
var x = trim(arguments.p);
x = reReplace(x, "[^0-9]", "", "all");
// Remove leading 1 if 11 digits
if (len(x) == 11 && left(x, 1) == "1") {
x = right(x, 10);
}
return x;
}
function generateOTP() {
return randRange(100000, 999999);
}
try {
data = readJsonBody();
phone = structKeyExists(data, "phone") ? normalizePhone(data.phone) : "";
if (len(phone) != 10) {
apiAbort({ "OK": false, "ERROR": "invalid_phone", "MESSAGE": "Please enter a valid 10-digit phone number" });
}
// Check if phone already has a COMPLETE account (verified AND has profile info)
// An account is only "complete" if they have a first name (meaning they finished signup)
qExisting = queryExecute("
SELECT UserID, UserUUID, UserFirstName
FROM Users
WHERE UserContactNumber = :phone
AND UserIsContactVerified > 0
AND UserFirstName IS NOT NULL
AND LENGTH(TRIM(UserFirstName)) > 0
LIMIT 1
", { phone: { value: phone, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" });
if (qExisting.recordCount > 0) {
apiAbort({ "OK": false, "ERROR": "phone_exists", "MESSAGE": "This phone number already has an account. Please login instead." });
}
// Check for incomplete account with this phone (verified but no profile, OR unverified)
// These accounts can be reused for signup
qIncomplete = queryExecute("
SELECT UserID, UserUUID
FROM Users
WHERE UserContactNumber = :phone
AND (UserIsContactVerified = 0 OR UserFirstName IS NULL OR LENGTH(TRIM(UserFirstName)) = 0)
LIMIT 1
", { phone: { value: phone, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" });
otp = generateOTP();
userUUID = "";
if (qIncomplete.recordCount > 0) {
// Update existing incomplete record with new OTP and reset for re-registration
userUUID = qIncomplete.UserUUID;
queryExecute("
UPDATE Users
SET UserMobileVerifyCode = :otp,
UserIsContactVerified = 0,
UserIsActive = 0
WHERE UserID = :userId
", {
otp: { value: otp, cfsqltype: "cf_sql_varchar" },
userId: { value: qIncomplete.UserID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
} else {
// Create new user record
userUUID = replace(createUUID(), "-", "", "all");
queryExecute("
INSERT INTO Users (
UserContactNumber,
UserUUID,
UserMobileVerifyCode,
UserIsContactVerified,
UserIsEmailVerified,
UserIsActive,
UserAddedOn,
UserPassword,
UserPromoCode
) VALUES (
:phone,
:uuid,
:otp,
0,
0,
0,
:addedOn,
'',
:promoCode
)
", {
phone: { value: phone, cfsqltype: "cf_sql_varchar" },
uuid: { value: userUUID, cfsqltype: "cf_sql_varchar" },
otp: { value: otp, cfsqltype: "cf_sql_varchar" },
addedOn: { value: now(), cfsqltype: "cf_sql_timestamp" },
promoCode: { value: randRange(1000000, 9999999), cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
}
// Send OTP via Twilio
smsResult = application.twilioObj.sendSMS(
recipientNumber: "+1" & phone,
messageBody: "Your Payfrit verification code is: " & otp
);
smsStatus = smsResult.success ? "sent" : "failed: " & smsResult.message;
writeOutput(serializeJSON({
"OK": true,
"UUID": userUUID,
"MESSAGE": smsResult.success ? "Verification code sent" : "SMS failed but code created - contact support",
"SMS_STATUS": smsStatus
}));
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -0,0 +1,72 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Validate a user token (for WebSocket server authentication)
// Input: Token
// Output: { OK: true, UserID: ..., UserType: 'customer'/'worker' }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
token = trim(structKeyExists(data, "Token") ? data.Token : "");
if (!len(token)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Token is required" });
}
// Look up the token
qToken = queryExecute("
SELECT ut.UserID, u.UserFirstName, u.UserLastName
FROM UserTokens ut
JOIN Users u ON u.UserID = ut.UserID
WHERE ut.Token = :token
LIMIT 1
", { token: { value: token, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" });
if (qToken.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "invalid_token", "MESSAGE": "Token is invalid or expired" });
}
userID = qToken.UserID;
// Determine if user is a worker (has any active employment)
qWorker = queryExecute("
SELECT COUNT(*) as cnt
FROM lt_Users_Businesses_Employees
WHERE UserID = :userID AND EmployeeIsActive = 1
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
userType = qWorker.cnt > 0 ? "worker" : "customer";
apiAbort({
"OK": true,
"UserID": userID,
"UserType": userType,
"UserName": trim(qToken.UserFirstName & " " & qToken.UserLastName)
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -0,0 +1,96 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Verify OTP for LOGIN (existing verified accounts)
*
* POST: { "uuid": "...", "otp": "123456" }
*
* Returns: { OK: true, UserID: 123, Token: "...", UserFirstName: "..." }
*/
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
userUUID = structKeyExists(data, "uuid") ? trim(data.uuid) : "";
otp = structKeyExists(data, "otp") ? trim(data.otp) : "";
if (!len(userUUID) || !len(otp)) {
apiAbort({ "OK": false, "ERROR": "missing_fields", "MESSAGE": "UUID and OTP are required" });
}
// Find verified user with matching UUID and OTP
qUser = queryExecute("
SELECT UserID, UserFirstName, UserLastName
FROM Users
WHERE UserUUID = :uuid
AND UserMobileVerifyCode = :otp
AND UserIsContactVerified = 1
LIMIT 1
", {
uuid: { value: userUUID, cfsqltype: "cf_sql_varchar" },
otp: { value: otp, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
if (qUser.recordCount == 0) {
// Check if UUID exists but OTP is wrong
qCheck = queryExecute("
SELECT UserID FROM Users WHERE UserUUID = :uuid AND UserIsContactVerified = 1
", { uuid: { value: userUUID, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" });
if (qCheck.recordCount > 0) {
apiAbort({ "OK": false, "ERROR": "invalid_otp", "MESSAGE": "Invalid code. Please try again." });
} else {
apiAbort({ "OK": false, "ERROR": "expired", "MESSAGE": "Session expired. Please request a new code." });
}
}
// Clear the OTP (one-time use)
queryExecute("
UPDATE Users
SET UserMobileVerifyCode = ''
WHERE UserID = :userId
", { userId: { value: qUser.UserID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
// Create auth token
token = replace(createUUID(), "-", "", "all");
queryExecute("
INSERT INTO UserTokens (UserID, Token) VALUES (:userId, :token)
", {
userId: { value: qUser.UserID, cfsqltype: "cf_sql_integer" },
token: { value: token, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
writeOutput(serializeJSON({
"OK": true,
"UserID": qUser.UserID,
"Token": token,
"UserFirstName": qUser.UserFirstName ?: ""
}));
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

106
api/auth/verifyOTP.cfm Normal file
View file

@ -0,0 +1,106 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Verify OTP and activate user account
*
* POST: { "uuid": "...", "otp": "123456" }
*
* Returns: { OK: true, UserID: 123, Token: "...", NeedsProfile: true/false }
*
* On success, marks phone as verified and returns auth token.
* NeedsProfile indicates if user still needs to provide name/email.
*/
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
userUUID = structKeyExists(data, "uuid") ? trim(data.uuid) : "";
otp = structKeyExists(data, "otp") ? trim(data.otp) : "";
if (!len(userUUID) || !len(otp)) {
apiAbort({ "OK": false, "ERROR": "missing_fields", "MESSAGE": "UUID and OTP are required" });
}
// Find unverified user with matching UUID and OTP
qUser = queryExecute("
SELECT UserID, UserFirstName, UserLastName, UserEmailAddress, UserIsEmailVerified
FROM Users
WHERE UserUUID = :uuid
AND UserMobileVerifyCode = :otp
AND UserIsContactVerified = 0
LIMIT 1
", {
uuid: { value: userUUID, cfsqltype: "cf_sql_varchar" },
otp: { value: otp, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
if (qUser.recordCount == 0) {
// Check if UUID exists but OTP is wrong
qCheck = queryExecute("
SELECT UserID FROM Users WHERE UserUUID = :uuid AND UserIsContactVerified = 0
", { uuid: { value: userUUID, cfsqltype: "cf_sql_varchar" } }, { datasource: "payfrit" });
if (qCheck.recordCount > 0) {
apiAbort({ "OK": false, "ERROR": "invalid_otp", "MESSAGE": "Invalid verification code. Please try again." });
} else {
apiAbort({ "OK": false, "ERROR": "expired", "MESSAGE": "Verification expired. Please request a new code." });
}
}
// Clear the OTP code (one-time use) but DON'T mark as verified yet
// Account will be marked verified after profile completion
queryExecute("
UPDATE Users
SET UserMobileVerifyCode = ''
WHERE UserID = :userId
", { userId: { value: qUser.UserID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
// Create auth token (needed for completeProfile call)
token = replace(createUUID(), "-", "", "all");
queryExecute("
INSERT INTO UserTokens (UserID, Token) VALUES (:userId, :token)
", {
userId: { value: qUser.UserID, cfsqltype: "cf_sql_integer" },
token: { value: token, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
// Check if profile is complete (has first name)
// For new signups, this will always be true
needsProfile = !len(trim(qUser.UserFirstName));
writeOutput(serializeJSON({
"OK": true,
"UserID": qUser.UserID,
"Token": token,
"NeedsProfile": needsProfile,
"UserFirstName": qUser.UserFirstName ?: "",
"IsEmailVerified": qUser.UserIsEmailVerified == 1
}));
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

57
api/chat/closeChat.cfm Normal file
View file

@ -0,0 +1,57 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Close/complete a chat task
// Input: TaskID
// Output: { OK: true }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
taskID = val(structKeyExists(data, "TaskID") ? data.TaskID : 0);
if (taskID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required" });
}
// Mark the task as completed
queryExecute("
UPDATE Tasks
SET TaskCompletedOn = NOW()
WHERE TaskID = :taskID
AND TaskTypeID = 2
AND TaskCompletedOn IS NULL
", {
taskID: { value: taskID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
apiAbort({
"OK": true,
"MESSAGE": "Chat closed"
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -0,0 +1,80 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Check for an active (uncompleted) chat task at a business
// Input: BusinessID, ServicePointID (optional), UserID (optional)
// Output: { OK: true, HAS_ACTIVE_CHAT: true/false, TASK_ID: ... }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
businessID = val(structKeyExists(data, "BusinessID") ? data.BusinessID : 0);
servicePointID = val(structKeyExists(data, "ServicePointID") ? data.ServicePointID : 0);
userID = val(structKeyExists(data, "UserID") ? data.UserID : 0);
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
// Look for any active chat task at this business (TaskTypeID = 2, not completed)
// Priority order:
// 1. Chats that are claimed (worker is responding)
// 2. Chats that have messages (ongoing conversation)
// 3. Most recently created chat
qChat = queryExecute("
SELECT t.TaskID, t.TaskTitle, t.TaskSourceID,
(SELECT COUNT(*) FROM ChatMessages m WHERE m.TaskID = t.TaskID) as MessageCount
FROM Tasks t
WHERE t.TaskBusinessID = :businessID
AND t.TaskTypeID = 2
AND t.TaskCompletedOn IS NULL
ORDER BY
CASE WHEN t.TaskClaimedByUserID > 0 THEN 0 ELSE 1 END,
(SELECT COUNT(*) FROM ChatMessages m WHERE m.TaskID = t.TaskID) DESC,
t.TaskAddedOn DESC
LIMIT 1
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
if (qChat.recordCount > 0) {
apiAbort({
"OK": true,
"HAS_ACTIVE_CHAT": true,
"TASK_ID": qChat.TaskID,
"TASK_TITLE": qChat.TaskTitle
});
} else {
apiAbort({
"OK": true,
"HAS_ACTIVE_CHAT": false,
"TASK_ID": 0,
"TASK_TITLE": ""
});
}
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

113
api/chat/getMessages.cfm Normal file
View file

@ -0,0 +1,113 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Get chat messages for a task
// Input: TaskID, AfterMessageID (optional - for pagination/polling)
// Output: { OK: true, MESSAGES: [...] }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
taskID = val(structKeyExists(data, "TaskID") ? data.TaskID : 0);
afterMessageID = val(structKeyExists(data, "AfterMessageID") ? data.AfterMessageID : 0);
if (taskID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required" });
}
// Get messages
if (afterMessageID > 0) {
qMessages = queryExecute("
SELECT
m.MessageID,
m.TaskID,
m.SenderUserID,
m.SenderType,
m.MessageText,
m.IsRead,
m.CreatedOn,
u.UserFirstName as SenderName
FROM ChatMessages m
LEFT JOIN Users u ON u.UserID = m.SenderUserID
WHERE m.TaskID = :taskID AND m.MessageID > :afterID
ORDER BY m.CreatedOn ASC
", {
taskID: { value: taskID, cfsqltype: "cf_sql_integer" },
afterID: { value: afterMessageID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
} else {
qMessages = queryExecute("
SELECT
m.MessageID,
m.TaskID,
m.SenderUserID,
m.SenderType,
m.MessageText,
m.IsRead,
m.CreatedOn,
u.UserFirstName as SenderName
FROM ChatMessages m
LEFT JOIN Users u ON u.UserID = m.SenderUserID
WHERE m.TaskID = :taskID
ORDER BY m.CreatedOn ASC
", {
taskID: { value: taskID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
}
messages = [];
for (msg in qMessages) {
arrayAppend(messages, {
"MessageID": msg.MessageID,
"TaskID": msg.TaskID,
"SenderUserID": msg.SenderUserID,
"SenderType": msg.SenderType,
"SenderName": len(trim(msg.SenderName)) ? msg.SenderName : (msg.SenderType == "customer" ? "Customer" : "Staff"),
"Text": msg.MessageText,
"IsRead": msg.IsRead == 1,
"CreatedOn": dateFormat(msg.CreatedOn, "yyyy-mm-dd") & "T" & timeFormat(msg.CreatedOn, "HH:mm:ss")
});
}
// Check if chat/task is closed (completed)
qTask = queryExecute("
SELECT TaskCompletedOn FROM Tasks WHERE TaskID = :taskID
", { taskID: { value: taskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
chatClosed = false;
if (qTask.recordCount > 0 && len(trim(qTask.TaskCompletedOn)) > 0) {
chatClosed = true;
}
apiAbort({
"OK": true,
"MESSAGES": messages,
"COUNT": arrayLen(messages),
"CHAT_CLOSED": chatClosed
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

65
api/chat/markRead.cfm Normal file
View file

@ -0,0 +1,65 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Mark messages as read
// Input: TaskID, ReaderType (customer/worker) - marks messages from the OTHER party as read
// Output: { OK: true }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
taskID = val(structKeyExists(data, "TaskID") ? data.TaskID : 0);
readerType = lcase(trim(structKeyExists(data, "ReaderType") ? data.ReaderType : ""));
if (taskID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required" });
}
if (readerType != "customer" && readerType != "worker") {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ReaderType must be 'customer' or 'worker'" });
}
// Mark messages from the OTHER party as read
// If reader is customer, mark worker messages as read
// If reader is worker, mark customer messages as read
otherType = readerType == "customer" ? "worker" : "customer";
queryExecute("
UPDATE ChatMessages
SET IsRead = 1
WHERE TaskID = :taskID AND SenderType = :otherType AND IsRead = 0
", {
taskID: { value: taskID, cfsqltype: "cf_sql_integer" },
otherType: { value: otherType, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
apiAbort({
"OK": true,
"MESSAGE": "Messages marked as read"
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

92
api/chat/sendMessage.cfm Normal file
View file

@ -0,0 +1,92 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Send a chat message (HTTP fallback when WebSocket not available)
// Input: TaskID, Message, SenderType (customer/worker)
// Output: { OK: true, MessageID: ... }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
taskID = val(structKeyExists(data, "TaskID") ? data.TaskID : 0);
message = trim(structKeyExists(data, "Message") ? data.Message : "");
senderType = lcase(trim(structKeyExists(data, "SenderType") ? data.SenderType : "customer"));
userID = val(structKeyExists(data, "UserID") ? data.UserID : 0);
// Also check request scope for authenticated user
if (userID == 0 && structKeyExists(request, "UserID")) {
userID = request.UserID;
}
if (taskID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required" });
}
if (!len(message)) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "Message is required" });
}
if (userID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "UserID is required" });
}
// Validate sender type
if (senderType != "customer" && senderType != "worker") {
senderType = "customer";
}
// Verify task exists
taskQuery = queryExecute("
SELECT TaskID, TaskClaimedByUserID FROM Tasks WHERE TaskID = :taskID
", { taskID: { value: taskID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (taskQuery.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found" });
}
// Insert message
queryExecute("
INSERT INTO ChatMessages (TaskID, SenderUserID, SenderType, MessageText)
VALUES (:taskID, :userID, :senderType, :message)
", {
taskID: { value: taskID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" },
senderType: { value: senderType, cfsqltype: "cf_sql_varchar" },
message: { value: message, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
// Get the new message ID
result = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
messageID = result.newID;
apiAbort({
"OK": true,
"MessageID": messageID,
"MESSAGE": "Message sent"
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

26
api/debug/tables.cfm Normal file
View file

@ -0,0 +1,26 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8">
<cfscript>
try {
qTables = queryExecute("SHOW TABLES LIKE '%tate%'", {}, { datasource: "payfrit" });
tables = [];
for (row in qTables) {
for (col in row) {
arrayAppend(tables, row[col]);
}
}
writeOutput(serializeJSON({
"OK": true,
"TABLES": tables
}));
} catch (any e) {
writeOutput(serializeJSON({
"OK": false,
"ERROR": e.message
}));
}
</cfscript>

View file

@ -1,5 +1,6 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<!--- Force recompile: 2026-01-09 --->
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
@ -49,18 +50,22 @@ try {
o.OrderUserID,
o.OrderServicePointID,
o.OrderStatusID,
o.OrderTypeID,
o.OrderRemarks,
o.OrderAddedOn,
o.OrderLastEditedOn,
o.OrderSubmittedOn,
u.UserFirstName,
u.UserLastName,
u.UserContactNumber,
u.UserEmailAddress,
sp.ServicePointName,
sp.ServicePointTypeID
sp.ServicePointTypeID,
b.BusinessName
FROM Orders o
LEFT JOIN Users u ON u.UserID = o.OrderUserID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID
LEFT JOIN Businesses b ON b.BusinessID = o.OrderBusinessID
WHERE o.OrderID = :orderID
", { orderID: orderID });
@ -71,7 +76,7 @@ try {
abort;
}
// Get line items
// Get line items (excluding deleted items)
qItems = queryExecute("
SELECT
oli.OrderLineItemID,
@ -81,10 +86,12 @@ try {
oli.OrderLineItemPrice,
oli.OrderLineItemRemark,
i.ItemName,
i.ItemPrice
i.ItemPrice,
i.ItemIsCheckedByDefault
FROM OrderLineItems oli
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
WHERE oli.OrderLineItemOrderID = :orderID
AND oli.OrderLineItemIsDeleted = 0
ORDER BY oli.OrderLineItemID
", { orderID: orderID });
@ -102,6 +109,7 @@ try {
"Quantity": row.OrderLineItemQuantity,
"UnitPrice": row.OrderLineItemPrice,
"Remarks": row.OrderLineItemRemark,
"IsDefault": (row.ItemIsCheckedByDefault == 1),
"Modifiers": []
};
itemsById[row.OrderLineItemID] = item;
@ -159,14 +167,18 @@ try {
order = {
"OrderID": qOrder.OrderID,
"BusinessID": qOrder.OrderBusinessID,
"BusinessName": qOrder.BusinessName ?: "",
"Status": qOrder.OrderStatusID,
"StatusText": getStatusText(qOrder.OrderStatusID),
"OrderTypeID": qOrder.OrderTypeID ?: 0,
"OrderTypeName": getOrderTypeName(qOrder.OrderTypeID ?: 0),
"Subtotal": subtotal,
"Tax": tax,
"Tip": tip,
"Total": total,
"Notes": qOrder.OrderRemarks,
"CreatedOn": dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"),
"SubmittedOn": len(qOrder.OrderSubmittedOn) ? dateTimeFormat(qOrder.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss") : "",
"UpdatedOn": len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "",
"Customer": {
"UserID": qOrder.OrderUserID,
@ -193,7 +205,7 @@ try {
writeOutput(serializeJSON(response));
// Helper function
// Helper functions
function getStatusText(status) {
switch (status) {
case 0: return "Cart";
@ -205,4 +217,13 @@ function getStatusText(status) {
default: return "Unknown";
}
}
function getOrderTypeName(orderType) {
switch (orderType) {
case 1: return "Dine-in";
case 2: return "Takeaway";
case 3: return "Delivery";
default: return "Unknown";
}
}
</cfscript>

97
api/portal/team.cfm Normal file
View file

@ -0,0 +1,97 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/*
PATH: /api/portal/team.cfm
INPUT (JSON):
{ "BusinessID": 17 }
OUTPUT (JSON):
{ OK: true, TEAM: [ { EmployeeID, UserID, Name, Email, Phone, StatusID, StatusName, IsActive } ] }
*/
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
data = readJsonBody();
businessId = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 0;
if (businessId <= 0) {
apiAbort({ "OK": false, "ERROR": "missing_business_id" });
}
try {
// Get employees for this business with user details
qTeam = queryExecute("
SELECT
e.EmployeeID,
e.UserID,
e.EmployeeStatusID,
e.EmployeeIsActive,
u.UserFirstName,
u.UserLastName,
u.UserEmailAddress,
u.UserContactNumber,
CASE e.EmployeeStatusID
WHEN 0 THEN 'Pending'
WHEN 1 THEN 'Invited'
WHEN 2 THEN 'Active'
WHEN 3 THEN 'Suspended'
ELSE 'Unknown'
END AS StatusName
FROM lt_Users_Businesses_Employees e
JOIN Users u ON e.UserID = u.UserID
WHERE e.BusinessID = ?
ORDER BY e.EmployeeIsActive DESC, u.UserFirstName ASC
", [
{ value: businessId, cfsqltype: "cf_sql_integer" }
], { datasource: "payfrit" });
team = [];
for (row in qTeam) {
arrayAppend(team, {
"EmployeeID": row.EmployeeID,
"UserID": row.UserID,
"Name": trim(row.UserFirstName & " " & row.UserLastName),
"FirstName": row.UserFirstName,
"LastName": row.UserLastName,
"Email": row.UserEmailAddress,
"Phone": row.UserContactNumber,
"StatusID": row.EmployeeStatusID,
"StatusName": row.StatusName,
"IsActive": row.EmployeeIsActive == 1
});
}
writeOutput(serializeJSON({
"OK": true,
"TEAM": team,
"COUNT": arrayLen(team)
}));
abort;
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

139
api/tasks/callServer.cfm Normal file
View file

@ -0,0 +1,139 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Customer calls server to their table
// Input: BusinessID, ServicePointID, OrderID (optional), Message (optional)
// Output: { OK: true, TASK_ID: ... }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
businessID = val(structKeyExists(data, "BusinessID") ? data.BusinessID : 0);
servicePointID = val(structKeyExists(data, "ServicePointID") ? data.ServicePointID : 0);
orderID = val(structKeyExists(data, "OrderID") ? data.OrderID : 0);
message = trim(structKeyExists(data, "Message") ? data.Message : "");
userID = val(structKeyExists(data, "UserID") ? data.UserID : 0);
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
if (servicePointID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" });
}
// Get service point info (table name)
spQuery = queryExecute("
SELECT ServicePointName FROM ServicePoints WHERE ServicePointID = :spID
", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
tableName = spQuery.recordCount ? spQuery.ServicePointName : "Table ##" & servicePointID;
// Get user name if available
userName = "";
if (userID > 0) {
userQuery = queryExecute("
SELECT UserFirstName FROM Users WHERE UserID = :userID
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (userQuery.recordCount && len(trim(userQuery.UserFirstName))) {
userName = userQuery.UserFirstName;
}
}
// Create task title and details
taskTitle = "Service Request - " & tableName;
taskDetails = "";
if (len(userName)) {
taskDetails &= "Customer: " & userName & chr(10);
}
if (len(message)) {
taskDetails &= "Request: " & message;
} else {
taskDetails &= "Customer is requesting assistance";
}
// Look up or create a "Service" category for this business
catQuery = queryExecute("
SELECT TaskCategoryID FROM TaskCategories
WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Service'
LIMIT 1
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (catQuery.recordCount == 0) {
// Create the category
queryExecute("
INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor)
VALUES (:businessID, 'Service', '##FF9800')
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
catResult = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
categoryID = catResult.newID;
} else {
categoryID = catQuery.TaskCategoryID;
}
// Insert task
queryExecute("
INSERT INTO Tasks (
TaskBusinessID,
TaskCategoryID,
TaskOrderID,
TaskTypeID,
TaskTitle,
TaskDetails,
TaskClaimedByUserID,
TaskAddedOn
) VALUES (
:businessID,
:categoryID,
:orderID,
1,
:title,
:details,
0,
NOW()
)
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 },
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
details: { value: taskDetails, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
// Get the new task ID
result = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
taskID = result.newID;
apiAbort({
"OK": true,
"TASK_ID": taskID,
"MESSAGE": "Server has been notified"
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -36,9 +36,9 @@
</cfif>
<cftry>
<!--- Verify task exists and is claimed by this user --->
<!--- Verify task exists --->
<cfset qTask = queryExecute("
SELECT TaskID, TaskClaimedByUserID, TaskCompletedOn, TaskOrderID
SELECT TaskID, TaskClaimedByUserID, TaskCompletedOn, TaskOrderID, TaskTypeID
FROM Tasks
WHERE TaskID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
@ -47,11 +47,14 @@
<cfset apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found." })>
</cfif>
<cfif qTask.TaskClaimedByUserID EQ 0>
<!--- Chat tasks (TaskTypeID = 2) can be closed without being claimed first --->
<cfset isChatTask = (qTask.TaskTypeID EQ 2)>
<cfif (NOT isChatTask) AND (qTask.TaskClaimedByUserID EQ 0)>
<cfset apiAbort({ "OK": false, "ERROR": "not_claimed", "MESSAGE": "Task has not been claimed yet." })>
</cfif>
<cfif UserID GT 0 AND qTask.TaskClaimedByUserID NEQ UserID>
<cfif (NOT isChatTask) AND (UserID GT 0) AND (qTask.TaskClaimedByUserID NEQ UserID)>
<cfset apiAbort({ "OK": false, "ERROR": "not_yours", "MESSAGE": "This task was claimed by someone else." })>
</cfif>

View file

@ -0,0 +1,80 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<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="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>
<cfset data = readJsonBody()>
<cfset TaskID = val( structKeyExists(data,"TaskID") ? data.TaskID : 0 )>
<cfif TaskID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required." })>
</cfif>
<cftry>
<!--- Verify task exists and is a chat task --->
<cfset qTask = queryExecute("
SELECT TaskID, TaskClaimedByUserID, TaskCompletedOn, TaskOrderID, TaskTypeID
FROM Tasks
WHERE TaskID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfif qTask.recordCount EQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found." })>
</cfif>
<!--- Only allow chat tasks (TaskTypeID = 2) --->
<cfif qTask.TaskTypeID NEQ 2>
<cfset apiAbort({ "OK": false, "ERROR": "not_chat", "MESSAGE": "This endpoint is only for chat tasks." })>
</cfif>
<!--- Check if already completed --->
<cfif len(trim(qTask.TaskCompletedOn)) GT 0>
<cfset apiAbort({ "OK": false, "ERROR": "already_completed", "MESSAGE": "Chat has already been closed." })>
</cfif>
<!--- Mark task as completed --->
<cfset queryExecute("
UPDATE Tasks
SET TaskCompletedOn = NOW()
WHERE TaskID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset apiAbort({
"OK": true,
"ERROR": "",
"MESSAGE": "Chat closed successfully.",
"TaskID": TaskID
})>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Error closing chat",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>

154
api/tasks/createChat.cfm Normal file
View file

@ -0,0 +1,154 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Customer initiates a chat with staff
// Input: BusinessID, ServicePointID, OrderID (optional), Message (optional initial message)
// Output: { OK: true, TaskID: ... }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
businessID = val(structKeyExists(data, "BusinessID") ? data.BusinessID : 0);
servicePointID = val(structKeyExists(data, "ServicePointID") ? data.ServicePointID : 0);
orderID = val(structKeyExists(data, "OrderID") ? data.OrderID : 0);
initialMessage = trim(structKeyExists(data, "Message") ? data.Message : "");
userID = val(structKeyExists(data, "UserID") ? data.UserID : 0);
if (businessID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
}
if (servicePointID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" });
}
// Get service point info (table name)
spQuery = queryExecute("
SELECT ServicePointName FROM ServicePoints WHERE ServicePointID = :spID
", { spID: { value: servicePointID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
tableName = spQuery.recordCount ? spQuery.ServicePointName : "Table ##" & servicePointID;
// Get user name if available
userName = "";
if (userID > 0) {
userQuery = queryExecute("
SELECT UserFirstName FROM Users WHERE UserID = :userID
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (userQuery.recordCount && len(trim(userQuery.UserFirstName))) {
userName = userQuery.UserFirstName;
}
}
// Create task title
taskTitle = "Chat - " & tableName;
if (len(userName)) {
taskTitle = "Chat - " & userName & " (" & tableName & ")";
}
taskDetails = "Customer initiated chat";
if (len(initialMessage)) {
taskDetails = initialMessage;
}
// Look up or create a "Chat" category for this business
catQuery = queryExecute("
SELECT TaskCategoryID FROM TaskCategories
WHERE TaskCategoryBusinessID = :businessID AND TaskCategoryName = 'Chat'
LIMIT 1
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (catQuery.recordCount == 0) {
// Create the category (blue color for chat)
queryExecute("
INSERT INTO TaskCategories (TaskCategoryBusinessID, TaskCategoryName, TaskCategoryColor)
VALUES (:businessID, 'Chat', '##2196F3')
", { businessID: { value: businessID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
catResult = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
categoryID = catResult.newID;
} else {
categoryID = catQuery.TaskCategoryID;
}
// Insert task with TaskTypeID = 2 (Chat)
queryExecute("
INSERT INTO Tasks (
TaskBusinessID,
TaskCategoryID,
TaskOrderID,
TaskTypeID,
TaskTitle,
TaskDetails,
TaskClaimedByUserID,
TaskSourceType,
TaskSourceID,
TaskAddedOn
) VALUES (
:businessID,
:categoryID,
:orderID,
2,
:title,
:details,
0,
'servicepoint',
:servicePointID,
NOW()
)
", {
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
categoryID: { value: categoryID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID > 0 ? orderID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: orderID == 0 },
title: { value: taskTitle, cfsqltype: "cf_sql_varchar" },
details: { value: taskDetails, cfsqltype: "cf_sql_varchar" },
servicePointID: { value: servicePointID, cfsqltype: "cf_sql_integer" }
}, { datasource: "payfrit" });
// Get the new task ID
result = queryExecute("SELECT LAST_INSERT_ID() as newID", [], { datasource: "payfrit" });
taskID = result.newID;
// If there's an initial message, save it
if (len(initialMessage) && userID > 0) {
queryExecute("
INSERT INTO ChatMessages (TaskID, SenderUserID, SenderType, MessageText)
VALUES (:taskID, :userID, 'customer', :message)
", {
taskID: { value: taskID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" },
message: { value: initialMessage, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
}
apiAbort({
"OK": true,
"TaskID": taskID,
"MESSAGE": "Chat started"
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -78,6 +78,22 @@
<cfset taskTitle = "Order ##" & qTask.TaskOrderID>
</cfif>
<!--- Check if user photo file exists (try both .jpg and .png) --->
<cfset customerPhotoUrl = "">
<cfif qTask.CustomerUserID GT 0>
<cfset uploadDir = expandPath("/uploads/users/")>
<cfset jpgPath = uploadDir & qTask.CustomerUserID & ".jpg">
<cfset pngPath = uploadDir & qTask.CustomerUserID & ".png">
<cfset pngPathUpper = uploadDir & qTask.CustomerUserID & ".PNG">
<cfif fileExists(jpgPath)>
<cfset customerPhotoUrl = "https://biz.payfrit.com/uploads/users/" & qTask.CustomerUserID & ".jpg">
<cfelseif fileExists(pngPath)>
<cfset customerPhotoUrl = "https://biz.payfrit.com/uploads/users/" & qTask.CustomerUserID & ".png">
<cfelseif fileExists(pngPathUpper)>
<cfset customerPhotoUrl = "https://biz.payfrit.com/uploads/users/" & qTask.CustomerUserID & ".PNG">
</cfif>
</cfif>
<cfset result = {
"TaskID": qTask.TaskID,
"TaskBusinessID": qTask.TaskBusinessID,
@ -100,7 +116,7 @@
"CustomerFirstName": qTask.UserFirstName ?: "",
"CustomerLastName": qTask.UserLastName ?: "",
"CustomerPhone": qTask.UserContactNumber ?: "",
"CustomerPhotoUrl": qTask.CustomerUserID GT 0 ? "https://biz.payfrit.com/uploads/users/" & qTask.CustomerUserID & ".jpg" : "",
"CustomerPhotoUrl": customerPhotoUrl,
"BeaconUUID": "",
"LineItems": []
}>

View file

@ -98,6 +98,7 @@
"TaskBusinessID": qTasks.TaskBusinessID,
"BusinessName": qTasks.BusinessName,
"TaskCategoryID": qTasks.TaskCategoryID,
"TaskTypeID": qTasks.TaskTypeID,
"TaskTitle": taskTitle,
"TaskDetails": "",
"TaskCreatedOn": dateFormat(qTasks.TaskAddedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskAddedOn, "HH:mm:ss"),

View file

@ -54,6 +54,8 @@
t.TaskCategoryID,
t.TaskOrderID,
t.TaskTypeID,
t.TaskTitle,
t.TaskDetails,
t.TaskAddedOn,
t.TaskClaimedByUserID,
tc.TaskCategoryName,
@ -67,18 +69,23 @@
<cfset tasks = []>
<cfloop query="qTasks">
<!--- Build title based on task type --->
<cfset taskTitle = "Task ##" & qTasks.TaskID>
<cfset taskDetails = "">
<cfif qTasks.TaskOrderID GT 0>
<!--- Use stored title if available, otherwise build from order --->
<cfset taskTitle = "">
<cfif len(trim(qTasks.TaskTitle))>
<cfset taskTitle = qTasks.TaskTitle>
<cfelseif qTasks.TaskOrderID GT 0>
<cfset taskTitle = "Order ##" & qTasks.TaskOrderID>
<cfelse>
<cfset taskTitle = "Task ##" & qTasks.TaskID>
</cfif>
<cfset taskDetails = len(trim(qTasks.TaskDetails)) ? qTasks.TaskDetails : "">
<cfset arrayAppend(tasks, {
"TaskID": qTasks.TaskID,
"TaskBusinessID": qTasks.TaskBusinessID,
"TaskCategoryID": qTasks.TaskCategoryID,
"TaskTypeID": qTasks.TaskTypeID,
"TaskTitle": taskTitle,
"TaskDetails": taskDetails,
"TaskCreatedOn": dateFormat(qTasks.TaskAddedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskAddedOn, "HH:mm:ss"),

93
api/users/search.cfm Normal file
View file

@ -0,0 +1,93 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// Search users by phone, email, or username
// Input: Query (search term)
// Output: { OK: true, USERS: [...] }
function apiAbort(required struct payload) {
writeOutput(serializeJSON(payload));
abort;
}
function readJsonBody() {
var raw = getHttpRequestData().content;
if (isNull(raw)) raw = "";
if (!len(trim(raw))) return {};
try {
var data = deserializeJSON(raw);
if (isStruct(data)) return data;
} catch (any e) {}
return {};
}
try {
data = readJsonBody();
query = structKeyExists(data, "Query") ? trim(data.Query) : "";
currentUserId = val(structKeyExists(data, "CurrentUserID") ? data.CurrentUserID : 0);
if (len(query) < 3) {
apiAbort({ "OK": true, "USERS": [], "MESSAGE": "Query must be at least 3 characters" });
}
// Search by phone, email, or first/last name
// Exclude the current user from results
searchTerm = "%" & query & "%";
qUsers = queryExecute("
SELECT
u.UserID,
u.UserFirstName,
u.UserLastName,
u.UserEmail,
u.UserPhone,
u.UserAvatarPath
FROM Users u
WHERE u.UserID != :currentUserId
AND (
u.UserPhone LIKE :searchTerm
OR u.UserEmail LIKE :searchTerm
OR u.UserFirstName LIKE :searchTerm
OR u.UserLastName LIKE :searchTerm
OR CONCAT(u.UserFirstName, ' ', u.UserLastName) LIKE :searchTerm
)
ORDER BY u.UserFirstName, u.UserLastName
LIMIT 10
", {
currentUserId: { value: currentUserId, cfsqltype: "cf_sql_integer" },
searchTerm: { value: searchTerm, cfsqltype: "cf_sql_varchar" }
}, { datasource: "payfrit" });
users = [];
for (user in qUsers) {
// Mask phone number for privacy (show last 4 digits only)
maskedPhone = "";
if (len(trim(user.UserPhone)) >= 4) {
maskedPhone = "***-***-" & right(user.UserPhone, 4);
}
arrayAppend(users, {
"UserID": user.UserID,
"Name": trim(user.UserFirstName & " " & user.UserLastName),
"Email": user.UserEmail,
"Phone": maskedPhone,
"AvatarUrl": len(trim(user.UserAvatarPath)) ? "https://biz.payfrit.com/uploads/" & user.UserAvatarPath : ""
});
}
apiAbort({
"OK": true,
"USERS": users,
"COUNT": arrayLen(users)
});
} catch (any e) {
apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": e.message
});
}
</cfscript>

View file

@ -12,7 +12,7 @@ const HUD = {
pollInterval: 1000, // Poll every second for smooth animation
targetSeconds: 60, // Target time to accept a task
apiBaseUrl: '/api/tasks', // API endpoint
businessId: 1, // TODO: Get from login/URL
businessId: parseInt(new URLSearchParams(window.location.search).get('b')) || 17,
},
// State
@ -150,10 +150,14 @@ const HUD = {
bar.dataset.taskId = task.TaskID;
bar.dataset.category = task.TaskCategoryID || 1;
// Apply dynamic category color
const categoryColor = this.getCategoryColor(task);
bar.style.setProperty('--bar-color', categoryColor);
bar.innerHTML = `
<span class="task-time"></span>
<span class="task-id">#${task.TaskID}</span>
<span class="task-label">${this.getCategoryName(task.TaskCategoryID)}</span>
<span class="task-label">${this.getCategoryName(task)}</span>
`;
// Touch/click handlers
@ -214,9 +218,20 @@ const HUD = {
return `${mins}:${String(secs).padStart(2, '0')}`;
},
// Get category name
getCategoryName(categoryId) {
return this.categories[categoryId]?.name || 'Task';
// Get category name - use task's category name if available, fall back to hardcoded
getCategoryName(task) {
if (task && task.TaskCategoryName) {
return task.TaskCategoryName;
}
return this.categories[task?.TaskCategoryID]?.name || 'Task';
},
// Get category color - use task's category color if available, fall back to hardcoded
getCategoryColor(task) {
if (task && task.TaskCategoryColor) {
return task.TaskCategoryColor;
}
return this.categories[task?.TaskCategoryID]?.color || '#888888';
},
// Handle bar press (start long press timer)
@ -250,16 +265,17 @@ const HUD = {
const overlay = document.getElementById('taskOverlay');
const detail = document.getElementById('taskDetail');
const category = this.categories[task.TaskCategoryID] || {};
const elapsed = this.getElapsedSeconds(task.TaskCreatedOn);
const categoryName = this.getCategoryName(task);
const categoryColor = this.getCategoryColor(task);
document.getElementById('detailTitle').textContent = task.TaskTitle || `Task #${task.TaskID}`;
document.getElementById('detailCategory').textContent = category.name || 'Unknown';
document.getElementById('detailCategory').textContent = categoryName;
document.getElementById('detailCreated').textContent = new Date(task.TaskCreatedOn).toLocaleTimeString();
document.getElementById('detailWaiting').textContent = this.formatElapsed(elapsed);
document.getElementById('detailInfo').textContent = task.TaskDetails || '-';
detail.style.setProperty('--detail-color', category.color || '#fff');
detail.style.setProperty('--detail-color', categoryColor);
overlay.classList.add('visible');
},

View file

@ -225,33 +225,40 @@
color: #444;
}
/* Category colors */
.task-bar[data-category="1"] {
/* Dynamic color from API (overrides category defaults) */
.task-bar {
--task-color: var(--bar-color, #888888);
--task-color-light: var(--bar-color, #aaaaaa);
--task-color-glow: var(--bar-color, rgba(136, 136, 136, 0.3));
}
/* Category color fallbacks (used when --bar-color not set) */
.task-bar[data-category="1"]:not([style*="--bar-color"]) {
--task-color: #ef4444;
--task-color-light: #f87171;
--task-color-glow: rgba(239, 68, 68, 0.3);
}
.task-bar[data-category="2"] {
.task-bar[data-category="2"]:not([style*="--bar-color"]) {
--task-color: #f59e0b;
--task-color-light: #fbbf24;
--task-color-glow: rgba(245, 158, 11, 0.3);
}
.task-bar[data-category="3"] {
.task-bar[data-category="3"]:not([style*="--bar-color"]) {
--task-color: #22c55e;
--task-color-light: #4ade80;
--task-color-glow: rgba(34, 197, 94, 0.3);
}
.task-bar[data-category="4"] {
.task-bar[data-category="4"]:not([style*="--bar-color"]) {
--task-color: #3b82f6;
--task-color-light: #60a5fa;
--task-color-glow: rgba(59, 130, 246, 0.3);
}
.task-bar[data-category="5"] {
.task-bar[data-category="5"]:not([style*="--bar-color"]) {
--task-color: #8b5cf6;
--task-color-light: #a78bfa;
--task-color-glow: rgba(139, 92, 246, 0.3);
}
.task-bar[data-category="6"] {
.task-bar[data-category="6"]:not([style*="--bar-color"]) {
--task-color: #ec4899;
--task-color-light: #f472b6;
--task-color-glow: rgba(236, 72, 153, 0.3);

View file

@ -1,6 +1,6 @@
// Configuration
let config = {
apiBaseUrl: '/biz.payfrit.com/api',
apiBaseUrl: 'https://biz.payfrit.com/api',
businessId: null,
servicePointId: null,
stationId: null,