diff --git a/api/Application.cfm b/api/Application.cfm index cd4649a..5de06bf 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -2,7 +2,7 @@ + + + + @@ -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; diff --git a/api/addresses/add.cfm b/api/addresses/add.cfm index 6aa147c..db41fd0 100644 --- a/api/addresses/add.cfm +++ b/api/addresses/add.cfm @@ -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" }); diff --git a/api/addresses/delete.cfm b/api/addresses/delete.cfm new file mode 100644 index 0000000..85d8af6 --- /dev/null +++ b/api/addresses/delete.cfm @@ -0,0 +1,103 @@ + + + + + + +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 + })); +} + diff --git a/api/addresses/list.cfm b/api/addresses/list.cfm index 69f67a8..138da9c 100644 --- a/api/addresses/list.cfm +++ b/api/addresses/list.cfm @@ -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" }); diff --git a/api/addresses/setDefault.cfm b/api/addresses/setDefault.cfm new file mode 100644 index 0000000..08e06a9 --- /dev/null +++ b/api/addresses/setDefault.cfm @@ -0,0 +1,95 @@ + + + + + + +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 + })); +} + diff --git a/api/addresses/states.cfm b/api/addresses/states.cfm new file mode 100644 index 0000000..a065293 --- /dev/null +++ b/api/addresses/states.cfm @@ -0,0 +1,35 @@ + + + + + + +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 + })); +} + diff --git a/api/admin/addTaskSourceColumns.cfm b/api/admin/addTaskSourceColumns.cfm new file mode 100644 index 0000000..20b5147 --- /dev/null +++ b/api/admin/addTaskSourceColumns.cfm @@ -0,0 +1,66 @@ + + + + + +// 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)); + diff --git a/api/admin/createChatMessagesTable.cfm b/api/admin/createChatMessagesTable.cfm new file mode 100644 index 0000000..7183532 --- /dev/null +++ b/api/admin/createChatMessagesTable.cfm @@ -0,0 +1,53 @@ + + + +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 + })); +} + diff --git a/api/admin/debugChatMessages.cfm b/api/admin/debugChatMessages.cfm new file mode 100644 index 0000000..27ad7fe --- /dev/null +++ b/api/admin/debugChatMessages.cfm @@ -0,0 +1,41 @@ + + + + + +// 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 + })); +} + diff --git a/api/admin/testTaskType.cfm b/api/admin/testTaskType.cfm new file mode 100644 index 0000000..41367c5 --- /dev/null +++ b/api/admin/testTaskType.cfm @@ -0,0 +1,17 @@ + + + +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 +})); + diff --git a/api/auth/completeProfile.cfm b/api/auth/completeProfile.cfm new file mode 100644 index 0000000..a5ebfde --- /dev/null +++ b/api/auth/completeProfile.cfm @@ -0,0 +1,143 @@ + + + + + + +/** + * Complete user profile after phone verification + * + * POST: { + * "firstName": "John", + * "lastName": "Smith", + * "email": "john@example.com" + * } + * + * Requires auth token in header: X-User-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 = " +

Welcome to Payfrit, #firstName#!

+

Please click the link below to confirm your email address:

+

Confirm Email

+

Or copy and paste this URL into your browser:

+

#confirmLink#

+

Thanks,
The Payfrit Team

+ "; +
+ + + #emailBody# + + + + 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 + }); +} + diff --git a/api/auth/loginOTP.cfm b/api/auth/loginOTP.cfm new file mode 100644 index 0000000..8869f9d --- /dev/null +++ b/api/auth/loginOTP.cfm @@ -0,0 +1,97 @@ + + + + + + +/** + * 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 + }); +} + diff --git a/api/auth/sendOTP.cfm b/api/auth/sendOTP.cfm new file mode 100644 index 0000000..871800a --- /dev/null +++ b/api/auth/sendOTP.cfm @@ -0,0 +1,155 @@ + + + + + + +/** + * 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 + }); +} + diff --git a/api/auth/validateToken.cfm b/api/auth/validateToken.cfm new file mode 100644 index 0000000..2251bdd --- /dev/null +++ b/api/auth/validateToken.cfm @@ -0,0 +1,72 @@ + + + + + +// 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 + }); +} + diff --git a/api/auth/verifyLoginOTP.cfm b/api/auth/verifyLoginOTP.cfm new file mode 100644 index 0000000..38adccb --- /dev/null +++ b/api/auth/verifyLoginOTP.cfm @@ -0,0 +1,96 @@ + + + + + + +/** + * 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 + }); +} + diff --git a/api/auth/verifyOTP.cfm b/api/auth/verifyOTP.cfm new file mode 100644 index 0000000..1b63fcf --- /dev/null +++ b/api/auth/verifyOTP.cfm @@ -0,0 +1,106 @@ + + + + + + +/** + * 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 + }); +} + diff --git a/api/chat/closeChat.cfm b/api/chat/closeChat.cfm new file mode 100644 index 0000000..ef56fb9 --- /dev/null +++ b/api/chat/closeChat.cfm @@ -0,0 +1,57 @@ + + + + + +// 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 + }); +} + diff --git a/api/chat/getActiveChat.cfm b/api/chat/getActiveChat.cfm new file mode 100644 index 0000000..7b52843 --- /dev/null +++ b/api/chat/getActiveChat.cfm @@ -0,0 +1,80 @@ + + + + + +// 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 + }); +} + diff --git a/api/chat/getMessages.cfm b/api/chat/getMessages.cfm new file mode 100644 index 0000000..90aea84 --- /dev/null +++ b/api/chat/getMessages.cfm @@ -0,0 +1,113 @@ + + + + + +// 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 + }); +} + diff --git a/api/chat/markRead.cfm b/api/chat/markRead.cfm new file mode 100644 index 0000000..a3e1818 --- /dev/null +++ b/api/chat/markRead.cfm @@ -0,0 +1,65 @@ + + + + + +// 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 + }); +} + diff --git a/api/chat/sendMessage.cfm b/api/chat/sendMessage.cfm new file mode 100644 index 0000000..8f6030d --- /dev/null +++ b/api/chat/sendMessage.cfm @@ -0,0 +1,92 @@ + + + + + +// 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 + }); +} + diff --git a/api/debug/tables.cfm b/api/debug/tables.cfm new file mode 100644 index 0000000..1e9a354 --- /dev/null +++ b/api/debug/tables.cfm @@ -0,0 +1,26 @@ + + + + + +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 + })); +} + diff --git a/api/orders/getDetail.cfm b/api/orders/getDetail.cfm index 3b3342a..100b861 100644 --- a/api/orders/getDetail.cfm +++ b/api/orders/getDetail.cfm @@ -1,5 +1,6 @@ + @@ -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"; + } +} diff --git a/api/portal/team.cfm b/api/portal/team.cfm new file mode 100644 index 0000000..05f8124 --- /dev/null +++ b/api/portal/team.cfm @@ -0,0 +1,97 @@ + + + + + + +/* + 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 + }); +} + diff --git a/api/tasks/callServer.cfm b/api/tasks/callServer.cfm new file mode 100644 index 0000000..f8a2fb8 --- /dev/null +++ b/api/tasks/callServer.cfm @@ -0,0 +1,139 @@ + + + + + +// 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 + }); +} + diff --git a/api/tasks/complete.cfm b/api/tasks/complete.cfm index ff7dbca..b78cc2d 100644 --- a/api/tasks/complete.cfm +++ b/api/tasks/complete.cfm @@ -36,9 +36,9 @@ - + @@ -47,11 +47,14 @@ - + + + + - + diff --git a/api/tasks/completeChat.cfm b/api/tasks/completeChat.cfm new file mode 100644 index 0000000..795cfd8 --- /dev/null +++ b/api/tasks/completeChat.cfm @@ -0,0 +1,80 @@ + + + + + + + #serializeJSON(arguments.payload)# + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/tasks/createChat.cfm b/api/tasks/createChat.cfm new file mode 100644 index 0000000..7e446df --- /dev/null +++ b/api/tasks/createChat.cfm @@ -0,0 +1,154 @@ + + + + + +// 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 + }); +} + diff --git a/api/tasks/getDetails.cfm b/api/tasks/getDetails.cfm index bbc33f1..a35c34c 100644 --- a/api/tasks/getDetails.cfm +++ b/api/tasks/getDetails.cfm @@ -78,6 +78,22 @@ + + + + + + + + + + + + + + + + diff --git a/api/tasks/listMine.cfm b/api/tasks/listMine.cfm index d17327c..bffe74e 100644 --- a/api/tasks/listMine.cfm +++ b/api/tasks/listMine.cfm @@ -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"), diff --git a/api/tasks/listPending.cfm b/api/tasks/listPending.cfm index c094d11..861504f 100644 --- a/api/tasks/listPending.cfm +++ b/api/tasks/listPending.cfm @@ -54,6 +54,8 @@ t.TaskCategoryID, t.TaskOrderID, t.TaskTypeID, + t.TaskTitle, + t.TaskDetails, t.TaskAddedOn, t.TaskClaimedByUserID, tc.TaskCategoryName, @@ -67,18 +69,23 @@ - - - - - + + + + + + + + + + + + + +// 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 + }); +} + diff --git a/hud/hud.js b/hud/hud.js index 6ee7019..4a11de7 100644 --- a/hud/hud.js +++ b/hud/hud.js @@ -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 = ` #${task.TaskID} - ${this.getCategoryName(task.TaskCategoryID)} + ${this.getCategoryName(task)} `; // 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'); }, diff --git a/hud/index.html b/hud/index.html index 3a34b21..14bf1f9 100644 --- a/hud/index.html +++ b/hud/index.html @@ -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); diff --git a/kds/kds.js b/kds/kds.js index a880491..a28d325 100644 --- a/kds/kds.js +++ b/kds/kds.js @@ -1,6 +1,6 @@ // Configuration let config = { - apiBaseUrl: '/biz.payfrit.com/api', + apiBaseUrl: 'https://biz.payfrit.com/api', businessId: null, servicePointId: null, stationId: null,