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 uploadsRoot(): string { return '/opt/payfrit-api/uploads'; } // ============================================ // 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']); } } }