payfrit-api/api/helpers.php
John Mizerek 1f81d98c52 Initial PHP API migration from CFML
Complete port of all 163 API endpoints from Lucee/CFML to PHP 8.3.
Shared helpers in api/helpers.php (DB, auth, request/response, security).
PDO prepared statements throughout. Same JSON response shapes as CFML.
2026-03-14 14:26:59 -07:00

484 lines
14 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';
}
// ============================================
// 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));
}
// ============================================
// 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, $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']);
}
}
}