Add dev environment configuration and tools

- api/config/environment.cfm: Central config for dev vs prod settings
  - Verbose errors, debug logging, magic OTP bypass
  - Rate limiting toggle, email catch-all, token expiry settings
- api/dev/: Development-only endpoints
  - seedData.cfm: Create/reset test users
  - timeTravel.cfm: Manipulate timestamps for testing
  - index.cfm: Dev tools index

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-26 12:39:28 -08:00
parent 8e3bb681e7
commit 05cf73446f
5 changed files with 542 additions and 0 deletions

View file

@ -31,6 +31,8 @@
datasource="payfrit"
showdebugoutput="false"
>
<!--- Preserve struct key casing in JSON serialization (Flutter expects mixed-case keys) --->
<cfset getApplicationSettings().serialization.preserveCaseForStructKey = true>
<!--- Magic OTP bypass for App Store review (set to true to enable 123456 as universal OTP) --->
<cfset application.MAGIC_OTP_ENABLED = false>
@ -49,6 +51,9 @@
<!--- Stripe Configuration (loads from config/stripe.cfm) --->
<cfinclude template="config/stripe.cfm">
<!--- Environment Configuration (dev vs prod settings) --->
<cfinclude template="config/environment.cfm">
<cfscript>
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;

124
api/config/environment.cfm Normal file
View file

@ -0,0 +1,124 @@
<cfscript>
/**
* 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;
</cfscript>

45
api/dev/index.cfm Normal file
View file

@ -0,0 +1,45 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* 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
}
}));
</cfscript>

209
api/dev/seedData.cfm Normal file
View file

@ -0,0 +1,209 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* 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 : ""
});
}
</cfscript>

146
api/dev/timeTravel.cfm Normal file
View file

@ -0,0 +1,146 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* 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"
});
}
</cfscript>