Custom client-facing error message * 'log' => Custom server log label * 'code' => HTTP response code (default: 500) * 'before_exit' => Callback function before exit * * @example * // Simple — use all defaults * db_check($sth, $answer); * * @example * // Custom client message * db_check($sth, $answer, [ * 'message' => "Contact not found." * ]); * * @example * // Custom log label + message * db_check($sth, $answer, [ * 'log' => "Failed to fetch contact", * 'message' => "Could not load contact. Please try again." * ]); * * @example * // Custom HTTP status code * db_check($sth, $answer, [ * 'code' => 403, * 'message' => "Access denied." * ]); * * @example * // Cleanup callback before exit * db_check($sth, $answer, [ * 'message' => "Upload failed.", * 'before_exit' => function() use ($tmp_file) { * if(file_exists($tmp_file)) unlink($tmp_file); * } * ]); */ function db_check($sth, &$answer, $options = []) { if ($sth->errorInfo()[0] != "00000" && !empty($sth->errorInfo()[0])) { $trace = debug_backtrace()[0]; $log_message = $options['log'] ?? "DB Error"; error_log($log_message . " in " . $trace['file'] . " line " . $trace['line'] . ": " . $sth->errorInfo()[2]); // production version $answer["message"] = $options['message'] ?? "A server error occurred. Please try again."; // development version $answer["message"] = $sth->errorInfo()[2]; http_response_code($options['code'] ?? 500); if(isset($options['before_exit'])) { call_user_func($options['before_exit']); } exit(json_encode($answer)); } } if(!empty($_SESSION["login_company_id"])){ // CSRF Validation — add right at the top of the logged-in block if($_SERVER['REQUEST_METHOD'] === 'POST'){ $csrf_token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if(empty($csrf_token) || $csrf_token !== $_SESSION['csrf_token']){ http_response_code(403); exit(json_encode(["message" => "Invalid request"])); } } // validate otp $sql = "SELECT `password` FROM user WHERE user_id = :company_id"; $sth = $pdo1->prepare($sql); $sth->execute([ ":company_id" => $_SESSION["login_user_id"] ]); db_check($sth, $answer); $password = $sth->fetchColumn(); /** Generate OTP */ function generateOTP($sercet_key, $time_step = 180, $length = 6){ $counter = floor($_SESSION["otpTime"] / $time_step); $data = pack("NN", 0, $counter); $hash = hash_hmac('sha1', $data, $sercet_key, true); $offset = ord(substr($hash, -1)) & 0x0F; $value = unpack("N", substr($hash, $offset, 4)); $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length); return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); } $otp = generateOTP($password); if( $_SESSION["otp"]!=$otp ){ $answer["message"] = "Your password has been reset, Please logout and login again."; exit(json_encode($answer)); } // check company accessibily $sql = "SELECT * FROM company_map_user WHERE company_id = :login_company_id and user_id = :login_user_id"; $sth = $pdo1->prepare($sql); $sth->execute([ ":login_company_id" => $_SESSION["login_company_id"], ":login_user_id" => $_SESSION["login_user_id"] ]); db_check($sth, $answer); $map = $sth->fetchAll(PDO::FETCH_ASSOC); if( count($map)==0 ){ $answer["message"] = "Your accessibility to this company has been removed."; exit(json_encode($answer)); } } // set up ANSWER $answer = array("success"=>0, "message"=>""); if (isset($_POST['json'])) { // Old Method: Data is wrapped in a JSON string $data = json_decode($_POST['json'], true); } else if (isset($_POST['otp'])) { // New Method: Data is sent directly (FormData) // We check for 'otp' because every request should have one $data = $_POST; } else { // Truly no data received $answer["message"] = "Request denied: No valid JSON payload or Form Data detected."; exit(json_encode($answer)); } // incase logging in if(!empty($_SESSION["login_company_id"])){ $company_id = (int)$_SESSION["login_company_id"]; $user_id = (int)$_SESSION["login_user_id"]; $uuid = bin2hex(random_bytes(16)); // create json for table logging $logging = array( "user_id" => $user_id, "data" => json_encode($data), "dt" => date('Y-m-d H:i:s'), "login" => date('Y-m-d H:i:s', $_SESSION["otpTime"]) ); } /** * HELPER CLASSES AND FUNCTIONS * These are designed to be reusable across different API endpoints for consistent data handling and error management. * They are not specific to any single operation and can be used wherever similar patterns arise. */ /** * Helper class to manage data consistency */ class WarehouseManager { private $pdo; private $company_id; public function __construct($pdo, $company_id) { $this->pdo = $pdo; $this->company_id = $company_id; } public function getWarehouseName($warehouse_id) { $sql = "SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id AND id = :warehouse_id"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":company_id" => $this->company_id, ":warehouse_id" => $warehouse_id ]); return $sth->fetchColumn(); } public function getStockContext($warehouse_id, $id) { $name = $this->getWarehouseName($warehouse_id); $table = "td_stock_" . $name; if (empty($id)) { return [ 'table' => $table, 'name' => $name, 'row' => [] ]; } $sql = "SELECT * FROM `$table` WHERE company_id = :company_id AND id = :id"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":company_id" => $this->company_id, ":id" => $id ]); return [ 'table' => $table, 'name' => $name, 'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: [] ]; } public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) { // Lock the row — other transactions wait here $sql = "SELECT id FROM warehouse_balance WHERE company_id = :company_id AND warehouse_id = :warehouse_id AND product_sku = :product_sku FOR UPDATE"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":company_id" => $this->company_id, ":warehouse_id" => $warehouse_id, ":product_sku" => $product_sku ]); if (!$sth->fetchColumn()) { $sql = "INSERT INTO warehouse_balance (company_id, warehouse_id, product_sku) VALUES (:company_id, :warehouse_id, :product_sku)"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":company_id" => $this->company_id, ":warehouse_id" => $warehouse_id, ":product_sku" => $product_sku ]); } $column = $type === 'in' ? 'total_in' : 'total_out'; // This is already atomic since it uses relative update $sql = "UPDATE warehouse_balance SET `$column` = `$column` - :old_qty + :new_qty WHERE company_id = :company_id AND warehouse_id = :warehouse_id AND product_sku = :product_sku"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":company_id" => $this->company_id, ":warehouse_id" => $warehouse_id, ":product_sku" => $product_sku, ":old_qty" => $old_qty, ":new_qty" => $new_qty ]); } } class FileUploader { private $target_dir; private $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']; private $max_size = 5 * 1024 * 1024; // 5MB private $uploaded_files = []; // newly uploaded filenames private $deleted_files = []; // files we deleted from disk public function __construct($target_dir) { $this->target_dir = rtrim($target_dir, '/') . '/'; $this->ensureDirectory(); } private function ensureDirectory() { if (!is_dir($this->target_dir)) { mkdir($this->target_dir, 0755, true); } if (!is_writable($this->target_dir)) { throw new Exception("Target directory is not writable."); } } /** * Remove files that user deleted in the UI */ public function cleanup($existing_csv, $keep_csv) { if (empty($existing_csv)) return; $existing = array_filter(array_map('trim', explode(',', $existing_csv))); $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : []; foreach ($existing as $file) { if (!in_array($file, $keep)) { $path = $this->target_dir . $file; if (file_exists($path)) { unlink($path); $this->deleted_files[] = $file; } } } } /** * Upload new files from $_FILES * Returns array of errors (empty = success) */ public function upload($field_name) { if (!isset($_FILES[$field_name])) return []; $errors = []; foreach ($_FILES[$field_name]['name'] as $key => $name) { $error_code = $_FILES[$field_name]['error'][$key]; if ($error_code !== UPLOAD_ERR_OK) { $errors[] = "{$name}: " . $this->getUploadError($error_code); continue; } $tmp_name = $_FILES[$field_name]['tmp_name'][$key]; $file_size = $_FILES[$field_name]['size'][$key]; $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if ($file_size > $this->max_size) { $errors[] = "{$name}: exceeds " . ($this->max_size / 1024 / 1024) . "MB limit"; continue; } if (!in_array($extension, $this->allowed_extensions)) { $errors[] = "{$name}: .{$extension} is not allowed"; continue; } $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tmp_name); finfo_close($finfo); if (!in_array($mime, $this->allowed_mimes)) { $errors[] = "{$name}: content type ({$mime}) is not allowed"; continue; } $file_id = uniqid() . "_" . time() . "." . $extension; $destination = $this->target_dir . $file_id; if (move_uploaded_file($tmp_name, $destination)) { $this->uploaded_files[] = $file_id; chmod($destination, 0644); } else { $errors[] = "{$name}: failed to save"; } } return $errors; } /** * Build final CSV string for DB */ public function buildFileString($keep_csv) { $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : []; $final = array_merge($keep, $this->uploaded_files); return implode(",", array_filter($final)); } /** * Rollback uploaded files if DB fails */ public function rollbackUploads() { foreach ($this->uploaded_files as $file) { $path = $this->target_dir . $file; if (file_exists($path)) { unlink($path); } } $this->uploaded_files = []; } private function getUploadError($code) { $messages = [ UPLOAD_ERR_INI_SIZE => "exceeds server max upload size (" . ini_get('upload_max_filesize') . ")", UPLOAD_ERR_FORM_SIZE => "exceeds form max size", UPLOAD_ERR_PARTIAL => "was only partially uploaded", UPLOAD_ERR_NO_FILE => "no file was sent", UPLOAD_ERR_NO_TMP_DIR => "server missing temp folder", UPLOAD_ERR_CANT_WRITE => "server failed to write to disk", UPLOAD_ERR_EXTENSION => "blocked by server extension", ]; return $messages[$code] ?? "unknown error (code {$code})"; } } /** * Helper function */ // transaction wrapper with retry logic for deadlocks function dbTransaction($pdo, $callback, $maxRetries = 3) { for ($attempt = 0; $attempt < $maxRetries; $attempt++) { try { $pdo->beginTransaction(); $result = $callback($pdo); $pdo->commit(); return $result; } catch (PDOException $e) { $pdo->rollBack(); if ($e->getCode() == '40001' && $attempt < $maxRetries - 1) { usleep(600000 * ($attempt + 1)); continue; } error_log("[DB ERROR] " . $e->getMessage() . " | " . $e->getFile() . ":" . $e->getLine()); throw $e; } catch (Exception $e) { $pdo->rollBack(); error_log("[ERROR] " . $e->getMessage() . " | " . $e->getFile() . ":" . $e->getLine()); throw $e; } } } ?>