diff --git a/api/Application.cfm b/api/Application.cfm index 937c1d1..3756cac 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -31,6 +31,8 @@ datasource="payfrit" showdebugoutput="false" > + + @@ -49,6 +51,9 @@ + + + function apiAbort(payload) { writeOutput(serializeJSON(payload)); @@ -145,6 +150,15 @@ if (len(request._api_path)) { 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; if (findNoCase("/api/tasks/expirestalechats.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/listCategories.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/saveCategory.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/deleteCategory.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/seedCategories.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/listAllTypes.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/listTypes.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/saveType.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/deleteType.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/tasks/reorderTypes.cfm", request._api_path)) request._api_isPublic = true; // Chat endpoints if (findNoCase("/api/chat/getMessages.cfm", request._api_path)) request._api_isPublic = true; @@ -240,6 +254,10 @@ if (len(request._api_path)) { // Stations endpoints if (findNoCase("/api/stations/list.cfm", request._api_path)) request._api_isPublic = true; + // Ratings endpoints (setup + token-based submission) + if (findNoCase("/api/ratings/setup.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/ratings/submit.cfm", request._api_path)) request._api_isPublic = true; + // Stripe endpoints if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/stripe/status.cfm", request._api_path)) request._api_isPublic = true; diff --git a/api/config/environment.cfm b/api/config/environment.cfm new file mode 100644 index 0000000..c9c325d --- /dev/null +++ b/api/config/environment.cfm @@ -0,0 +1,124 @@ + +/** + * Environment Configuration + * + * Controls dev vs production behavior. + * This file should have DIFFERENT values on dev vs biz servers. + * + * dev.payfrit.com: isDevEnvironment = true + * biz.payfrit.com: isDevEnvironment = false + */ + +// ============================================ +// ENVIRONMENT FLAG - CHANGE PER SERVER +// ============================================ +isDevEnvironment = true; // Set to FALSE on biz.payfrit.com + +// ============================================ +// ERROR HANDLING +// ============================================ +// Dev: Show full stack traces +// Prod: Show generic "server error" message +application.showDetailedErrors = isDevEnvironment; + +// ============================================ +// DEBUG LOGGING +// ============================================ +// Dev: Log all API requests/responses +// Prod: Minimal logging +application.debugLogging = isDevEnvironment; +application.logDirectory = expandPath("/api/logs/"); + +// ============================================ +// RATE LIMITING +// ============================================ +// Dev: No rate limits +// Prod: Enforce rate limits +application.enableRateLimiting = !isDevEnvironment; +application.rateLimitPerMinute = 60; // requests per minute per IP + +// ============================================ +// EMAIL HANDLING +// ============================================ +// Dev: Send all emails to catch-all address +// Prod: Send to real recipients +application.emailCatchAll = isDevEnvironment ? "dev-emails@payfrit.com" : ""; +application.emailEnabled = !isDevEnvironment ? true : false; // Disable emails on dev entirely + +// ============================================ +// MAGIC OTP (for testing) +// ============================================ +// Dev: Allow magic phone number to bypass OTP +// Prod: Disabled +application.MAGIC_OTP_ENABLED = isDevEnvironment; +application.MAGIC_OTP_CODE = "123456"; +application.MAGIC_PHONE_NUMBERS = ["5555555555", "0000000000"]; + +// ============================================ +// STRIPE MODE +// ============================================ +// Already handled in stripe.cfm, but good to have reference +application.stripeTestMode = isDevEnvironment; + +// ============================================ +// API RESPONSE EXTRAS +// ============================================ +// Dev: Include debug info in API responses (timing, queries, etc) +// Prod: Minimal responses +application.includeDebugInResponse = isDevEnvironment; + +// ============================================ +// SESSION/TOKEN SETTINGS +// ============================================ +// Dev: Longer token expiry for easier testing +// Prod: Normal expiry +application.tokenExpiryHours = isDevEnvironment ? 720 : 24; // 30 days vs 1 day + +// ============================================ +// HELPER FUNCTIONS +// ============================================ +function isDev() { + return structKeyExists(application, "isDevEnvironment") && application.isDevEnvironment; +} + +function logDebug(message, data = {}) { + if (!application.debugLogging) return; + + var logFile = application.logDirectory & "debug_" & dateFormat(now(), "yyyy-mm-dd") & ".log"; + var logLine = "[" & timeFormat(now(), "HH:mm:ss") & "] " & message; + + if (!structIsEmpty(data)) { + logLine &= " | " & serializeJSON(data); + } + + try { + if (!directoryExists(application.logDirectory)) { + directoryCreate(application.logDirectory); + } + fileAppend(logFile, logLine & chr(10)); + } catch (any e) { + // Silent fail - don't break app if logging fails + } +} + +function apiError(message, detail = "", statusCode = 500) { + var response = { + "OK": false, + "ERROR": "server_error", + "MESSAGE": application.showDetailedErrors ? message : "An error occurred" + }; + + if (application.showDetailedErrors && len(detail)) { + response["DETAIL"] = detail; + response["STACK"] = ""; + } + + return response; +} + +// Store in application scope +application.isDevEnvironment = isDevEnvironment; +application.isDev = isDev; +application.logDebug = logDebug; +application.apiError = apiError; + diff --git a/api/dev/index.cfm b/api/dev/index.cfm new file mode 100644 index 0000000..1112b98 --- /dev/null +++ b/api/dev/index.cfm @@ -0,0 +1,45 @@ + + + + +/** + * Dev Tools Index - DEV ONLY + * Lists available development endpoints + */ + +// SAFETY: Only allow on dev environment +if (!structKeyExists(application, "isDevEnvironment") || !application.isDevEnvironment) { + writeOutput(serializeJSON({ + "OK": false, + "ERROR": "forbidden", + "MESSAGE": "Dev tools are disabled on this server" + })); + abort; +} + +writeOutput(serializeJSON({ + "OK": true, + "environment": "development", + "endpoints": { + "seedData": { + "url": "/api/dev/seedData.cfm", + "methods": ["GET", "POST"], + "description": "Create and manage test users", + "actions": ["seed", "reset"] + }, + "timeTravel": { + "url": "/api/dev/timeTravel.cfm", + "methods": ["POST"], + "description": "Manipulate timestamps for testing", + "actions": ["expireTokens", "setUserCreated", "clearOTPs", "resetUser"] + } + }, + "config": { + "magicOTP": application.MAGIC_OTP_CODE, + "magicPhones": application.MAGIC_PHONE_NUMBERS, + "debugLogging": application.debugLogging, + "showDetailedErrors": application.showDetailedErrors, + "tokenExpiryHours": application.tokenExpiryHours + } +})); + diff --git a/api/dev/seedData.cfm b/api/dev/seedData.cfm new file mode 100644 index 0000000..0895217 --- /dev/null +++ b/api/dev/seedData.cfm @@ -0,0 +1,209 @@ + + + + + +/** + * Seed Data Script - DEV ONLY + * + * Creates test users and data for development testing. + * This endpoint is disabled on production. + * + * GET: Returns current seed data info + * POST: { "action": "seed" } - Creates seed data + * POST: { "action": "reset" } - Clears all test data and re-seeds + */ + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +// SAFETY: Only allow on dev environment +if (!structKeyExists(application, "isDevEnvironment") || !application.isDevEnvironment) { + apiAbort({ + "OK": false, + "ERROR": "forbidden", + "MESSAGE": "This endpoint is only available in development" + }); +} + +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 createTestUser(phone, firstName, lastName, email, isVerified = true) { + var userUUID = replace(createUUID(), "-", "", "all"); + + queryExecute(" + INSERT INTO Users ( + UserContactNumber, + UserUUID, + UserFirstName, + UserLastName, + UserEmail, + UserIsContactVerified, + UserIsEmailVerified, + UserIsActive, + UserAddedOn, + UserPassword, + UserPromoCode, + UserMobileVerifyCode + ) VALUES ( + :phone, + :uuid, + :firstName, + :lastName, + :email, + :isVerified, + 0, + :isVerified, + :addedOn, + '', + :promoCode, + '123456' + ) + ", { + phone: { value: phone, cfsqltype: "cf_sql_varchar" }, + uuid: { value: userUUID, cfsqltype: "cf_sql_varchar" }, + firstName: { value: firstName, cfsqltype: "cf_sql_varchar" }, + lastName: { value: lastName, cfsqltype: "cf_sql_varchar" }, + email: { value: email, cfsqltype: "cf_sql_varchar" }, + isVerified: { value: isVerified ? 1 : 0, cfsqltype: "cf_sql_integer" }, + addedOn: { value: now(), cfsqltype: "cf_sql_timestamp" }, + promoCode: { value: randRange(1000000, 9999999), cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + + return userUUID; +} + +function seedTestData() { + var created = []; + + // Test User 1: Magic phone (always works with OTP 123456) + try { + createTestUser("5555555555", "Magic", "User", "magic@test.payfrit.com", true); + arrayAppend(created, "Magic User (5555555555)"); + } catch (any e) { + if (e.message contains "Duplicate") { + arrayAppend(created, "Magic User already exists"); + } else { + arrayAppend(created, "Magic User failed: " & e.message); + } + } + + // Test User 2: Regular verified user + try { + createTestUser("5551234567", "Test", "Customer", "test@test.payfrit.com", true); + arrayAppend(created, "Test Customer (5551234567)"); + } catch (any e) { + if (e.message contains "Duplicate") { + arrayAppend(created, "Test Customer already exists"); + } else { + arrayAppend(created, "Test Customer failed: " & e.message); + } + } + + // Test User 3: Unverified user (for testing signup flow) + try { + createTestUser("5559876543", "", "", "", false); + arrayAppend(created, "Unverified User (5559876543)"); + } catch (any e) { + if (e.message contains "Duplicate") { + arrayAppend(created, "Unverified User already exists"); + } else { + arrayAppend(created, "Unverified User failed: " & e.message); + } + } + + return created; +} + +function resetTestData() { + // Delete test users (by phone prefix 555) + queryExecute(" + DELETE FROM Users + WHERE UserContactNumber LIKE '555%' + ", {}, { datasource: "payfrit" }); + + return seedTestData(); +} + +function getTestDataInfo() { + var qUsers = queryExecute(" + SELECT UserID, UserContactNumber, UserFirstName, UserLastName, + UserIsContactVerified, UserUUID + FROM Users + WHERE UserContactNumber LIKE '555%' + ORDER BY UserContactNumber + ", {}, { datasource: "payfrit" }); + + var users = []; + for (var row in qUsers) { + arrayAppend(users, { + "phone": row.UserContactNumber, + "name": trim(row.UserFirstName & " " & row.UserLastName), + "verified": row.UserIsContactVerified == 1, + "uuid": row.UserUUID + }); + } + + return users; +} + +try { + requestMethod = cgi.request_method; + + if (requestMethod == "GET") { + // Return current test data info + writeOutput(serializeJSON({ + "OK": true, + "testUsers": getTestDataInfo(), + "magicOTP": application.MAGIC_OTP_CODE, + "magicPhones": application.MAGIC_PHONE_NUMBERS + })); + } else if (requestMethod == "POST") { + data = readJsonBody(); + action = structKeyExists(data, "action") ? lcase(data.action) : "seed"; + + if (action == "reset") { + created = resetTestData(); + writeOutput(serializeJSON({ + "OK": true, + "action": "reset", + "created": created, + "testUsers": getTestDataInfo() + })); + } else { + created = seedTestData(); + writeOutput(serializeJSON({ + "OK": true, + "action": "seed", + "created": created, + "testUsers": getTestDataInfo() + })); + } + } else { + apiAbort({ + "OK": false, + "ERROR": "method_not_allowed", + "MESSAGE": "Use GET to view or POST to seed" + }); + } + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": application.showDetailedErrors ? e.message : "An error occurred", + "DETAIL": application.showDetailedErrors ? e.detail : "" + }); +} + diff --git a/api/dev/timeTravel.cfm b/api/dev/timeTravel.cfm new file mode 100644 index 0000000..12c728a --- /dev/null +++ b/api/dev/timeTravel.cfm @@ -0,0 +1,146 @@ + + + + + +/** + * Time Travel Endpoint - DEV ONLY + * + * Allows manipulation of timestamps for testing time-based features. + * Examples: token expiry, trial periods, scheduled events. + * + * POST: { "action": "expireTokens", "userId": 123 } + * POST: { "action": "setUserCreated", "userId": 123, "daysAgo": 30 } + */ + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +// SAFETY: Only allow on dev environment +if (!structKeyExists(application, "isDevEnvironment") || !application.isDevEnvironment) { + apiAbort({ + "OK": false, + "ERROR": "forbidden", + "MESSAGE": "This endpoint is only available in development" + }); +} + +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 { + if (cgi.request_method != "POST") { + apiAbort({ + "OK": false, + "ERROR": "method_not_allowed", + "MESSAGE": "POST required" + }); + } + + data = readJsonBody(); + action = structKeyExists(data, "action") ? lcase(data.action) : ""; + + switch (action) { + case "expiretokens": + // Expire all tokens for a user (simulate session timeout) + if (!structKeyExists(data, "userId")) { + apiAbort({ "OK": false, "ERROR": "missing_userId" }); + } + + queryExecute(" + UPDATE UserTokens + SET CreatedAt = DATE_SUB(NOW(), INTERVAL 30 DAY) + WHERE UserID = :userId + ", { + userId: { value: data.userId, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "message": "Tokens expired for user " & data.userId + })); + break; + + case "setusercreated": + // Backdate user creation (for testing trial periods, etc) + if (!structKeyExists(data, "userId") || !structKeyExists(data, "daysAgo")) { + apiAbort({ "OK": false, "ERROR": "missing_userId_or_daysAgo" }); + } + + queryExecute(" + UPDATE Users + SET UserAddedOn = DATE_SUB(NOW(), INTERVAL :days DAY) + WHERE UserID = :userId + ", { + userId: { value: data.userId, cfsqltype: "cf_sql_integer" }, + days: { value: data.daysAgo, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "message": "User " & data.userId & " created date set to " & data.daysAgo & " days ago" + })); + break; + + case "clearotps": + // Clear all OTPs (force re-request) + queryExecute(" + UPDATE Users SET UserMobileVerifyCode = '' + ", {}, { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "message": "All OTPs cleared" + })); + break; + + case "resetuser": + // Reset a user to unverified state (for retesting signup) + if (!structKeyExists(data, "phone")) { + apiAbort({ "OK": false, "ERROR": "missing_phone" }); + } + + queryExecute(" + UPDATE Users + SET UserIsContactVerified = 0, + UserIsActive = 0, + UserFirstName = NULL, + UserLastName = NULL, + UserMobileVerifyCode = '123456' + WHERE UserContactNumber = :phone + ", { + phone: { value: data.phone, cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + + writeOutput(serializeJSON({ + "OK": true, + "message": "User with phone " & data.phone & " reset to unverified" + })); + break; + + default: + apiAbort({ + "OK": false, + "ERROR": "unknown_action", + "MESSAGE": "Valid actions: expireTokens, setUserCreated, clearOTPs, resetUser" + }); + } + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": application.showDetailedErrors ? e.message : "An error occurred" + }); +} +