Added luceeWebroot() helper to avoid repeating the path. The previous fix incorrectly used /var/www/biz.payfrit.com for production, but both dev and biz use the same Lucee webroot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
543 lines
15 KiB
PHP
543 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* Payfrit API Helpers
|
|
*
|
|
* Core functions shared by all API endpoints.
|
|
* Include this at the top of every endpoint file.
|
|
*/
|
|
|
|
// Timezone: everything is UTC
|
|
date_default_timezone_set('UTC');
|
|
|
|
// No-cache + CORS headers on every response
|
|
header('Cache-Control: no-store');
|
|
header('Access-Control-Allow-Origin: *');
|
|
header('Access-Control-Allow-Headers: Content-Type, X-User-Token, X-Business-ID');
|
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
|
|
|
// Handle OPTIONS preflight
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
|
|
// ============================================
|
|
// DATABASE
|
|
// ============================================
|
|
|
|
function getDb(): PDO {
|
|
static $pdo = null;
|
|
if ($pdo !== null) return $pdo;
|
|
|
|
// Auto-detect environment by hostname
|
|
$hostname = gethostname();
|
|
$isDev = ($hostname !== 'biz');
|
|
|
|
$host = '10.10.0.1';
|
|
$dbname = $isDev ? 'payfrit_dev' : 'payfrit';
|
|
$user = 'payfrit_app';
|
|
$pass = $isDev ? 'Bv9#hLs4Wq@zK8nR' : 'Xm7@wT5jY';
|
|
|
|
$pdo = new PDO(
|
|
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
|
|
$user,
|
|
$pass,
|
|
[
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
]
|
|
);
|
|
return $pdo;
|
|
}
|
|
|
|
// ============================================
|
|
// PERFORMANCE TRACKING
|
|
// ============================================
|
|
|
|
$_perfStart = hrtime(true);
|
|
$_perfQueryCount = 0;
|
|
$_perfQueryTimeMs = 0;
|
|
|
|
/**
|
|
* Execute a prepared query with timing.
|
|
* Returns an array of associative rows for SELECT, or the PDOStatement for INSERT/UPDATE/DELETE.
|
|
*/
|
|
function queryTimed(string $sql, array $params = []): array|PDOStatement {
|
|
global $_perfQueryCount, $_perfQueryTimeMs;
|
|
|
|
$db = getDb();
|
|
$start = hrtime(true);
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute($params);
|
|
$elapsed = (hrtime(true) - $start) / 1_000_000; // nanoseconds to ms
|
|
|
|
$_perfQueryCount++;
|
|
$_perfQueryTimeMs += $elapsed;
|
|
|
|
// If it's a SELECT, return rows. Otherwise return the statement.
|
|
if (stripos(trim($sql), 'SELECT') === 0) {
|
|
return $stmt->fetchAll();
|
|
}
|
|
return $stmt;
|
|
}
|
|
|
|
/**
|
|
* Execute a SELECT query and return a single row, or null if not found.
|
|
*/
|
|
function queryOne(string $sql, array $params = []): ?array {
|
|
$rows = queryTimed($sql, $params);
|
|
return $rows[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Get the last inserted auto-increment ID.
|
|
*/
|
|
function lastInsertId(): string {
|
|
return getDb()->lastInsertId();
|
|
}
|
|
|
|
// ============================================
|
|
// REQUEST HELPERS
|
|
// ============================================
|
|
|
|
/**
|
|
* Read and parse the JSON request body.
|
|
*/
|
|
function readJsonBody(): array {
|
|
$raw = file_get_contents('php://input');
|
|
if (empty($raw)) return [];
|
|
$data = json_decode($raw, true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
/**
|
|
* Get a request header value (case-insensitive).
|
|
*/
|
|
function headerValue(string $name): string {
|
|
// PHP converts headers to HTTP_UPPER_SNAKE_CASE in $_SERVER
|
|
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
|
return trim($_SERVER[$key] ?? '');
|
|
}
|
|
|
|
// ============================================
|
|
// RESPONSE HELPERS
|
|
// ============================================
|
|
|
|
/**
|
|
* Send a JSON response and exit.
|
|
*/
|
|
function jsonResponse(array $payload, int $statusCode = 200): never {
|
|
http_response_code($statusCode);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Send an error response and exit.
|
|
*/
|
|
function apiAbort(array $payload): never {
|
|
jsonResponse($payload);
|
|
}
|
|
|
|
// ============================================
|
|
// DATE HELPERS
|
|
// ============================================
|
|
|
|
/**
|
|
* Format a datetime string as ISO 8601 UTC.
|
|
* Input is already UTC from the database.
|
|
*/
|
|
function toISO8601(?string $d): string {
|
|
if (empty($d)) return '';
|
|
$dt = new DateTime($d, new DateTimeZone('UTC'));
|
|
return $dt->format('Y-m-d\TH:i:s\Z');
|
|
}
|
|
|
|
/**
|
|
* Get current time in a specific timezone (for business hours checking).
|
|
*/
|
|
function getTimeInZone(string $tz = 'America/Los_Angeles'): string {
|
|
try {
|
|
$now = new DateTime('now', new DateTimeZone($tz));
|
|
return $now->format('H:i:s');
|
|
} catch (Exception) {
|
|
return (new DateTime('now', new DateTimeZone('UTC')))->format('H:i:s');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current day of week in a specific timezone (1=Sunday, 7=Saturday).
|
|
*/
|
|
function getDayInZone(string $tz = 'America/Los_Angeles'): int {
|
|
try {
|
|
$now = new DateTime('now', new DateTimeZone($tz));
|
|
return (int) $now->format('w') + 1; // 'w' is 0=Sun, we want 1=Sun
|
|
} catch (Exception) {
|
|
return (int) (new DateTime())->format('w') + 1;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// ENVIRONMENT
|
|
// ============================================
|
|
|
|
function isDev(): bool {
|
|
return gethostname() !== 'biz';
|
|
}
|
|
|
|
function baseUrl(): string {
|
|
return isDev() ? 'https://dev.payfrit.com' : 'https://biz.payfrit.com';
|
|
}
|
|
|
|
function luceeWebroot(): string {
|
|
return '/opt/lucee/tomcat/webapps/ROOT';
|
|
}
|
|
|
|
// ============================================
|
|
// PHONE HELPERS
|
|
// ============================================
|
|
|
|
/**
|
|
* Strip a US phone number to 10 digits (remove country code, formatting).
|
|
*/
|
|
function normalizePhone(string $p): string {
|
|
$digits = preg_replace('/[^0-9]/', '', trim($p));
|
|
if (strlen($digits) === 11 && $digits[0] === '1') {
|
|
$digits = substr($digits, 1);
|
|
}
|
|
return $digits;
|
|
}
|
|
|
|
/**
|
|
* Check if a string looks like a phone number (10-11 digits).
|
|
*/
|
|
function isPhoneNumber(string $input): bool {
|
|
$digits = preg_replace('/[^0-9]/', '', $input);
|
|
return strlen($digits) >= 10 && strlen($digits) <= 11;
|
|
}
|
|
|
|
// ============================================
|
|
// SECURITY
|
|
// ============================================
|
|
|
|
/**
|
|
* Generate a secure random token (256-bit, SHA-256 hashed).
|
|
*/
|
|
function generateSecureToken(): string {
|
|
return hash('sha256', random_bytes(32));
|
|
}
|
|
|
|
/**
|
|
* Generate a v4 UUID.
|
|
*/
|
|
function generateUUID(): string {
|
|
$bytes = random_bytes(16);
|
|
// Set version 4 bits
|
|
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
|
|
// Set variant bits
|
|
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
|
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
|
}
|
|
|
|
// ============================================
|
|
// TWILIO SMS
|
|
// ============================================
|
|
|
|
/**
|
|
* Send an SMS via Twilio REST API.
|
|
* Returns ['success' => bool, 'message' => string].
|
|
* Skips sending on dev (returns success with note).
|
|
*/
|
|
function sendSMS(string $to, string $body): array {
|
|
if (isDev()) {
|
|
return ['success' => true, 'message' => 'SMS skipped in dev'];
|
|
}
|
|
|
|
$configPath = realpath(__DIR__ . '/../config/twilio.json');
|
|
if (!$configPath || !file_exists($configPath)) {
|
|
return ['success' => false, 'message' => 'Twilio config not found'];
|
|
}
|
|
|
|
$config = json_decode(file_get_contents($configPath), true);
|
|
$sid = $config['accountSid'] ?? '';
|
|
$token = $config['authToken'] ?? '';
|
|
$from = $config['fromNumber'] ?? '';
|
|
|
|
if (empty($sid) || empty($token) || empty($from)) {
|
|
return ['success' => false, 'message' => 'Twilio config incomplete'];
|
|
}
|
|
|
|
$url = "https://api.twilio.com/2010-04-01/Accounts/{$sid}/Messages.json";
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_USERPWD => "{$sid}:{$token}",
|
|
CURLOPT_POSTFIELDS => http_build_query([
|
|
'From' => $from,
|
|
'To' => $to,
|
|
'Body' => $body,
|
|
]),
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode === 201) {
|
|
return ['success' => true, 'message' => 'Message sent'];
|
|
}
|
|
|
|
$parsed = json_decode($response, true);
|
|
$errMsg = $parsed['message'] ?? "HTTP $httpCode";
|
|
return ['success' => false, 'message' => $errMsg];
|
|
}
|
|
|
|
// ============================================
|
|
// AUTH MIDDLEWARE
|
|
// ============================================
|
|
|
|
// Public routes that don't require authentication
|
|
const PUBLIC_ROUTES = [
|
|
// auth
|
|
'/api/auth/login.php',
|
|
'/api/auth/logout.php',
|
|
'/api/auth/sendOTP.php',
|
|
'/api/auth/verifyOTP.php',
|
|
'/api/auth/loginOTP.php',
|
|
'/api/auth/verifyLoginOTP.php',
|
|
'/api/auth/sendLoginOTP.php',
|
|
'/api/auth/verifyEmailOTP.php',
|
|
'/api/auth/completeProfile.php',
|
|
'/api/auth/validateToken.php',
|
|
'/api/auth/profile.php',
|
|
'/api/auth/avatar.php',
|
|
// businesses
|
|
'/api/businesses/list.php',
|
|
'/api/businesses/get.php',
|
|
'/api/businesses/getChildren.php',
|
|
'/api/businesses/update.php',
|
|
'/api/businesses/updateHours.php',
|
|
'/api/businesses/updateTabs.php',
|
|
'/api/businesses/setHiring.php',
|
|
'/api/businesses/saveBrandColor.php',
|
|
'/api/businesses/saveOrderTypes.php',
|
|
// servicepoints
|
|
'/api/servicepoints/list.php',
|
|
'/api/servicepoints/get.php',
|
|
'/api/servicepoints/save.php',
|
|
'/api/servicepoints/delete.php',
|
|
'/api/servicepoints/reassign_all.php',
|
|
// beacons
|
|
'/api/beacons/list.php',
|
|
'/api/beacons/get.php',
|
|
'/api/beacons/save.php',
|
|
'/api/beacons/delete.php',
|
|
'/api/beacons/list_all.php',
|
|
'/api/beacons/getBusinessFromBeacon.php',
|
|
'/api/beacons/reassign_all.php',
|
|
'/api/beacons/lookup.php',
|
|
// beacon-sharding
|
|
'/api/beacon-sharding/allocate_business_namespace.php',
|
|
'/api/beacon-sharding/allocate_servicepoint_minor.php',
|
|
'/api/beacon-sharding/get_beacon_config.php',
|
|
'/api/beacon-sharding/get_shard_pool.php',
|
|
'/api/beacon-sharding/register_beacon_hardware.php',
|
|
'/api/beacon-sharding/resolve_business.php',
|
|
'/api/beacon-sharding/resolve_servicepoint.php',
|
|
'/api/beacon-sharding/verify_beacon_broadcast.php',
|
|
// menu
|
|
'/api/menu/items.php',
|
|
'/api/menu/getForBuilder.php',
|
|
'/api/menu/saveFromBuilder.php',
|
|
'/api/menu/updateStations.php',
|
|
'/api/menu/menus.php',
|
|
'/api/menu/uploadHeader.php',
|
|
'/api/menu/uploadItemPhoto.php',
|
|
'/api/menu/listCategories.php',
|
|
'/api/menu/saveCategory.php',
|
|
'/api/menu/clearAllData.php',
|
|
'/api/menu/clearBusinessData.php',
|
|
'/api/menu/clearOrders.php',
|
|
'/api/menu/debug.php',
|
|
// orders
|
|
'/api/orders/getOrCreateCart.php',
|
|
'/api/orders/getCart.php',
|
|
'/api/orders/getActiveCart.php',
|
|
'/api/orders/setLineItem.php',
|
|
'/api/orders/setOrderType.php',
|
|
'/api/orders/submit.php',
|
|
'/api/orders/submitCash.php',
|
|
'/api/orders/abandonOrder.php',
|
|
'/api/orders/listForKDS.php',
|
|
'/api/orders/updateStatus.php',
|
|
'/api/orders/markStationDone.php',
|
|
'/api/orders/checkStatusUpdate.php',
|
|
'/api/orders/getDetail.php',
|
|
'/api/orders/history.php',
|
|
'/api/orders/getPendingForUser.php',
|
|
// addresses
|
|
'/api/addresses/states.php',
|
|
'/api/addresses/list.php',
|
|
'/api/addresses/add.php',
|
|
'/api/addresses/delete.php',
|
|
'/api/addresses/setDefault.php',
|
|
'/api/addresses/types.php',
|
|
// assignments
|
|
'/api/assignments/list.php',
|
|
'/api/assignments/save.php',
|
|
'/api/assignments/delete.php',
|
|
// tasks
|
|
'/api/tasks/listPending.php',
|
|
'/api/tasks/accept.php',
|
|
'/api/tasks/listMine.php',
|
|
'/api/tasks/complete.php',
|
|
'/api/tasks/completeChat.php',
|
|
'/api/tasks/getDetails.php',
|
|
'/api/tasks/create.php',
|
|
'/api/tasks/createChat.php',
|
|
'/api/tasks/callServer.php',
|
|
'/api/tasks/expireStaleChats.php',
|
|
'/api/tasks/listCategories.php',
|
|
'/api/tasks/saveCategory.php',
|
|
'/api/tasks/deleteCategory.php',
|
|
'/api/tasks/seedCategories.php',
|
|
'/api/tasks/listAllTypes.php',
|
|
'/api/tasks/listTypes.php',
|
|
'/api/tasks/saveType.php',
|
|
'/api/tasks/deleteType.php',
|
|
'/api/tasks/reorderTypes.php',
|
|
// chat
|
|
'/api/chat/getMessages.php',
|
|
'/api/chat/sendMessage.php',
|
|
'/api/chat/markRead.php',
|
|
'/api/chat/getActiveChat.php',
|
|
'/api/chat/closeChat.php',
|
|
// workers
|
|
'/api/workers/myBusinesses.php',
|
|
'/api/workers/tierStatus.php',
|
|
'/api/workers/createAccount.php',
|
|
'/api/workers/onboardingLink.php',
|
|
'/api/workers/earlyUnlock.php',
|
|
'/api/workers/ledger.php',
|
|
// portal
|
|
'/api/portal/stats.php',
|
|
'/api/portal/myBusinesses.php',
|
|
'/api/portal/team.php',
|
|
'/api/portal/searchUser.php',
|
|
'/api/portal/addTeamMember.php',
|
|
'/api/portal/reassign_employees.php',
|
|
// users
|
|
'/api/users/search.php',
|
|
// stations
|
|
'/api/stations/list.php',
|
|
'/api/stations/save.php',
|
|
'/api/stations/delete.php',
|
|
// ratings
|
|
'/api/ratings/setup.php',
|
|
'/api/ratings/submit.php',
|
|
'/api/ratings/createAdminRating.php',
|
|
'/api/ratings/listForAdmin.php',
|
|
// app
|
|
'/api/app/about.php',
|
|
// grants
|
|
'/api/grants/create.php',
|
|
'/api/grants/list.php',
|
|
'/api/grants/get.php',
|
|
'/api/grants/update.php',
|
|
'/api/grants/revoke.php',
|
|
'/api/grants/accept.php',
|
|
'/api/grants/decline.php',
|
|
'/api/grants/searchBusiness.php',
|
|
// stripe
|
|
'/api/stripe/onboard.php',
|
|
'/api/stripe/status.php',
|
|
'/api/stripe/createPaymentIntent.php',
|
|
'/api/stripe/getPaymentConfig.php',
|
|
'/api/stripe/webhook.php',
|
|
// setup
|
|
'/api/setup/importBusiness.php',
|
|
'/api/setup/analyzeMenu.php',
|
|
'/api/setup/analyzeMenuImages.php',
|
|
'/api/setup/analyzeMenuUrl.php',
|
|
'/api/setup/uploadSavedPage.php',
|
|
'/api/setup/saveWizard.php',
|
|
'/api/setup/downloadImages.php',
|
|
'/api/setup/checkDuplicate.php',
|
|
'/api/setup/lookupTaxRate.php',
|
|
'/api/setup/reimportBigDeans.php',
|
|
'/api/setup/testUpload.php',
|
|
// presence
|
|
'/api/presence/heartbeat.php',
|
|
// tabs
|
|
'/api/tabs/open.php',
|
|
'/api/tabs/close.php',
|
|
'/api/tabs/get.php',
|
|
'/api/tabs/getActive.php',
|
|
'/api/tabs/getPresence.php',
|
|
'/api/tabs/addMember.php',
|
|
'/api/tabs/removeMember.php',
|
|
'/api/tabs/addOrder.php',
|
|
'/api/tabs/approveOrder.php',
|
|
'/api/tabs/rejectOrder.php',
|
|
'/api/tabs/pendingOrders.php',
|
|
'/api/tabs/increaseAuth.php',
|
|
'/api/tabs/cancel.php',
|
|
];
|
|
|
|
/**
|
|
* Run auth check. Call at the top of every endpoint after including helpers.
|
|
* Sets global $userId and $businessId.
|
|
* Returns early for public routes.
|
|
*/
|
|
$userId = 0;
|
|
$businessId = 0;
|
|
|
|
function runAuth(): void {
|
|
global $userId, $businessId;
|
|
|
|
$path = strtolower($_SERVER['SCRIPT_NAME'] ?? '');
|
|
|
|
// Check token
|
|
$token = headerValue('X-User-Token');
|
|
if (!empty($token)) {
|
|
$row = queryOne(
|
|
"SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1",
|
|
[$token]
|
|
);
|
|
if ($row) {
|
|
$userId = (int) $row['UserID'];
|
|
}
|
|
}
|
|
|
|
// Business header
|
|
$bizHeader = headerValue('X-Business-ID');
|
|
if (!empty($bizHeader) && is_numeric($bizHeader)) {
|
|
$businessId = (int) $bizHeader;
|
|
}
|
|
|
|
// Check if public route
|
|
$isPublic = false;
|
|
foreach (PUBLIC_ROUTES as $route) {
|
|
if (str_contains($path, strtolower($route))) {
|
|
$isPublic = true;
|
|
break;
|
|
}
|
|
}
|
|
// Also allow /api/admin/ paths
|
|
if (str_contains($path, '/api/admin/')) {
|
|
$isPublic = true;
|
|
}
|
|
|
|
if (!$isPublic) {
|
|
if ($userId <= 0) {
|
|
apiAbort(['OK' => false, 'ERROR' => 'not_logged_in']);
|
|
}
|
|
if ($businessId <= 0) {
|
|
apiAbort(['OK' => false, 'ERROR' => 'no_business_selected']);
|
|
}
|
|
}
|
|
}
|