From d2a8c73e2ca121e498c9e208b7ab0a7b469464b0 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 29 Jan 2026 10:53:26 -0500 Subject: [PATCH] Add caching layer and database helper - Add CacheHelper for file-based caching with TTL support - Add Database helper for centralized connection management - Update WorkflowModel to cache status transitions (10 min TTL) - Update UserPreferencesModel to cache user prefs (5 min TTL) - Update manage_workflows.php to clear cache on changes - Update get_users.php to use Database helper (example migration) Co-Authored-By: Claude Opus 4.5 --- api/get_users.php | 17 +-- api/manage_workflows.php | 14 ++- helpers/CacheHelper.php | 191 ++++++++++++++++++++++++++++++++ helpers/Database.php | 174 +++++++++++++++++++++++++++++ models/UserPreferencesModel.php | 85 +++++++++----- models/WorkflowModel.php | 114 +++++++++++-------- 6 files changed, 504 insertions(+), 91 deletions(-) create mode 100644 helpers/CacheHelper.php create mode 100644 helpers/Database.php diff --git a/api/get_users.php b/api/get_users.php index 5b4d467..37b1743 100644 --- a/api/get_users.php +++ b/api/get_users.php @@ -12,6 +12,7 @@ RateLimitMiddleware::apply('api'); try { require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; // Check authentication (session already started by RateLimitMiddleware) if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { @@ -20,22 +21,10 @@ try { exit; } - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } - header('Content-Type: application/json'); // Get all users for mentions/assignment - $sql = "SELECT user_id, username, display_name FROM users ORDER BY display_name, username"; - $result = $conn->query($sql); + $result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username"); if (!$result) { throw new Exception("Failed to query users"); @@ -52,8 +41,6 @@ try { echo json_encode(['success' => true, 'users' => $users]); - $conn->close(); - } catch (Exception $e) { http_response_code(500); echo json_encode(['success' => false, 'error' => $e->getMessage()]); diff --git a/api/manage_workflows.php b/api/manage_workflows.php index b433bec..6e97ffd 100644 --- a/api/manage_workflows.php +++ b/api/manage_workflows.php @@ -8,6 +8,7 @@ ini_set('display_errors', 0); error_reporting(E_ALL); require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; +require_once dirname(__DIR__) . '/models/WorkflowModel.php'; RateLimitMiddleware::apply('api'); try { @@ -91,6 +92,7 @@ try { ); if ($stmt->execute()) { + WorkflowModel::clearCache(); // Clear workflow cache echo json_encode(['success' => true, 'transition_id' => $conn->insert_id]); } else { echo json_encode(['success' => false, 'error' => $stmt->error]); @@ -118,7 +120,11 @@ try { $id ); - echo json_encode(['success' => $stmt->execute()]); + $success = $stmt->execute(); + if ($success) { + WorkflowModel::clearCache(); // Clear workflow cache + } + echo json_encode(['success' => $success]); $stmt->close(); break; @@ -130,7 +136,11 @@ try { $stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?"); $stmt->bind_param('i', $id); - echo json_encode(['success' => $stmt->execute()]); + $success = $stmt->execute(); + if ($success) { + WorkflowModel::clearCache(); // Clear workflow cache + } + echo json_encode(['success' => $success]); $stmt->close(); break; diff --git a/helpers/CacheHelper.php b/helpers/CacheHelper.php new file mode 100644 index 0000000..a204bcc --- /dev/null +++ b/helpers/CacheHelper.php @@ -0,0 +1,191 @@ + time(), + 'data' => $data + ]; + + // Store in memory cache + self::$memoryCache[$key] = $cached; + + // Store in file cache + $filePath = self::getCacheDir() . '/' . $key . '.json'; + return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false; + } + + /** + * Delete cached data + * + * @param string $prefix Cache prefix + * @param mixed $identifier Unique identifier (null to delete all with prefix) + * @return bool Success + */ + public static function delete($prefix, $identifier = null) { + if ($identifier !== null) { + $key = self::makeKey($prefix, $identifier); + unset(self::$memoryCache[$key]); + $filePath = self::getCacheDir() . '/' . $key . '.json'; + return !file_exists($filePath) || @unlink($filePath); + } + + // Delete all files with this prefix + $pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json'; + $files = glob($pattern); + foreach ($files as $file) { + @unlink($file); + } + + // Clear memory cache entries with this prefix + foreach (array_keys(self::$memoryCache) as $key) { + if (strpos($key, $prefix) === 0) { + unset(self::$memoryCache[$key]); + } + } + + return true; + } + + /** + * Clear all cache + * + * @return bool Success + */ + public static function clearAll() { + self::$memoryCache = []; + + $files = glob(self::getCacheDir() . '/*.json'); + foreach ($files as $file) { + @unlink($file); + } + + return true; + } + + /** + * Get data from cache or fetch it using a callback + * + * @param string $prefix Cache prefix + * @param mixed $identifier Unique identifier + * @param callable $callback Function to call if cache miss + * @param int $ttl Time-to-live in seconds + * @return mixed Cached or freshly fetched data + */ + public static function remember($prefix, $identifier, $callback, $ttl = 300) { + $data = self::get($prefix, $identifier, $ttl); + + if ($data === null) { + $data = $callback(); + if ($data !== null) { + self::set($prefix, $identifier, $data); + } + } + + return $data; + } + + /** + * Clean up expired cache files (call periodically) + * + * @param int $maxAge Maximum age in seconds (default 1 hour) + */ + public static function cleanup($maxAge = 3600) { + $files = glob(self::getCacheDir() . '/*.json'); + $now = time(); + + foreach ($files as $file) { + if ($now - filemtime($file) > $maxAge) { + @unlink($file); + } + } + } +} diff --git a/helpers/Database.php b/helpers/Database.php new file mode 100644 index 0000000..484b8f9 --- /dev/null +++ b/helpers/Database.php @@ -0,0 +1,174 @@ +ping()) { + self::$connection = self::createConnection(); + } + + return self::$connection; + } + + /** + * Create a new database connection + * + * @return mysqli Database connection + * @throws Exception If connection fails + */ + private static function createConnection() { + // Ensure config is loaded + if (!isset($GLOBALS['config'])) { + require_once dirname(__DIR__) . '/config/config.php'; + } + + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed: " . $conn->connect_error); + } + + // Set charset to utf8mb4 for proper Unicode support + $conn->set_charset('utf8mb4'); + + return $conn; + } + + /** + * Close the database connection + */ + public static function close() { + if (self::$connection !== null) { + self::$connection->close(); + self::$connection = null; + } + } + + /** + * Begin a transaction + * + * @return bool Success + */ + public static function beginTransaction() { + return self::getConnection()->begin_transaction(); + } + + /** + * Commit a transaction + * + * @return bool Success + */ + public static function commit() { + return self::getConnection()->commit(); + } + + /** + * Rollback a transaction + * + * @return bool Success + */ + public static function rollback() { + return self::getConnection()->rollback(); + } + + /** + * Execute a query and return results + * + * @param string $sql SQL query with placeholders + * @param string $types Parameter types (i=int, s=string, d=double, b=blob) + * @param array $params Parameters to bind + * @return mysqli_result|bool Query result + */ + public static function query($sql, $types = '', $params = []) { + $conn = self::getConnection(); + + if (empty($types) || empty($params)) { + return $conn->query($sql); + } + + $stmt = $conn->prepare($sql); + if (!$stmt) { + throw new Exception("Query preparation failed: " . $conn->error); + } + + $stmt->bind_param($types, ...$params); + $stmt->execute(); + + $result = $stmt->get_result(); + $stmt->close(); + + return $result; + } + + /** + * Execute an INSERT/UPDATE/DELETE and return affected rows + * + * @param string $sql SQL query with placeholders + * @param string $types Parameter types + * @param array $params Parameters to bind + * @return int Affected rows (-1 on failure) + */ + public static function execute($sql, $types = '', $params = []) { + $conn = self::getConnection(); + + $stmt = $conn->prepare($sql); + if (!$stmt) { + throw new Exception("Query preparation failed: " . $conn->error); + } + + if (!empty($types) && !empty($params)) { + $stmt->bind_param($types, ...$params); + } + + if ($stmt->execute()) { + $affected = $stmt->affected_rows; + $stmt->close(); + return $affected; + } + + $error = $stmt->error; + $stmt->close(); + throw new Exception("Query execution failed: " . $error); + } + + /** + * Get the last insert ID + * + * @return int Last insert ID + */ + public static function lastInsertId() { + return self::getConnection()->insert_id; + } + + /** + * Escape a string for use in queries (prefer prepared statements) + * + * @param string $string String to escape + * @return string Escaped string + */ + public static function escape($string) { + return self::getConnection()->real_escape_string($string); + } +} diff --git a/models/UserPreferencesModel.php b/models/UserPreferencesModel.php index d326bad..cfa5c92 100644 --- a/models/UserPreferencesModel.php +++ b/models/UserPreferencesModel.php @@ -1,35 +1,41 @@ conn = $conn; } /** - * Get all preferences for a user + * Get all preferences for a user (with caching) * @param int $userId User ID * @return array Associative array of preference_key => preference_value */ public function getUserPreferences($userId) { - $sql = "SELECT preference_key, preference_value - FROM user_preferences - WHERE user_id = ?"; - $stmt = $this->conn->prepare($sql); - $stmt->bind_param("i", $userId); - $stmt->execute(); - $result = $stmt->get_result(); + return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function() use ($userId) { + $sql = "SELECT preference_key, preference_value + FROM user_preferences + WHERE user_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("i", $userId); + $stmt->execute(); + $result = $stmt->get_result(); - $prefs = []; - while ($row = $result->fetch_assoc()) { - $prefs[$row['preference_key']] = $row['preference_value']; - } - return $prefs; + $prefs = []; + while ($row = $result->fetch_assoc()) { + $prefs[$row['preference_key']] = $row['preference_value']; + } + $stmt->close(); + return $prefs; + }, self::$CACHE_TTL); } /** @@ -45,7 +51,15 @@ class UserPreferencesModel { ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)"; $stmt = $this->conn->prepare($sql); $stmt->bind_param("iss", $userId, $key, $value); - return $stmt->execute(); + $result = $stmt->execute(); + $stmt->close(); + + // Invalidate cache for this user + if ($result) { + CacheHelper::delete(self::$CACHE_PREFIX, $userId); + } + + return $result; } /** @@ -56,17 +70,8 @@ class UserPreferencesModel { * @return mixed Preference value or default */ public function getPreference($userId, $key, $default = null) { - $sql = "SELECT preference_value FROM user_preferences - WHERE user_id = ? AND preference_key = ?"; - $stmt = $this->conn->prepare($sql); - $stmt->bind_param("is", $userId, $key); - $stmt->execute(); - $result = $stmt->get_result(); - - if ($row = $result->fetch_assoc()) { - return $row['preference_value']; - } - return $default; + $prefs = $this->getUserPreferences($userId); + return $prefs[$key] ?? $default; } /** @@ -80,7 +85,15 @@ class UserPreferencesModel { WHERE user_id = ? AND preference_key = ?"; $stmt = $this->conn->prepare($sql); $stmt->bind_param("is", $userId, $key); - return $stmt->execute(); + $result = $stmt->execute(); + $stmt->close(); + + // Invalidate cache for this user + if ($result) { + CacheHelper::delete(self::$CACHE_PREFIX, $userId); + } + + return $result; } /** @@ -92,7 +105,21 @@ class UserPreferencesModel { $sql = "DELETE FROM user_preferences WHERE user_id = ?"; $stmt = $this->conn->prepare($sql); $stmt->bind_param("i", $userId); - return $stmt->execute(); + $result = $stmt->execute(); + $stmt->close(); + + // Invalidate cache for this user + if ($result) { + CacheHelper::delete(self::$CACHE_PREFIX, $userId); + } + + return $result; + } + + /** + * Clear all user preferences cache + */ + public static function clearCache() { + CacheHelper::delete(self::$CACHE_PREFIX); } } -?> diff --git a/models/WorkflowModel.php b/models/WorkflowModel.php index f9931f4..c4a0308 100644 --- a/models/WorkflowModel.php +++ b/models/WorkflowModel.php @@ -1,14 +1,49 @@ conn = $conn; } + /** + * Get all active transitions (with caching) + * + * @return array All active transitions indexed by from_status + */ + private function getAllTransitions() { + return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function() { + $sql = "SELECT from_status, to_status, requires_comment, requires_admin + FROM status_transitions + WHERE is_active = TRUE"; + $result = $this->conn->query($sql); + + $transitions = []; + while ($row = $result->fetch_assoc()) { + $from = $row['from_status']; + if (!isset($transitions[$from])) { + $transitions[$from] = []; + } + $transitions[$from][$row['to_status']] = [ + 'to_status' => $row['to_status'], + 'requires_comment' => (bool)$row['requires_comment'], + 'requires_admin' => (bool)$row['requires_admin'] + ]; + } + + return $transitions; + }, self::$CACHE_TTL); + } + /** * Get allowed status transitions for a given status * @@ -16,21 +51,13 @@ class WorkflowModel { * @return array Array of allowed transitions with requirements */ public function getAllowedTransitions($currentStatus) { - $sql = "SELECT to_status, requires_comment, requires_admin - FROM status_transitions - WHERE from_status = ? AND is_active = TRUE"; - $stmt = $this->conn->prepare($sql); - $stmt->bind_param("s", $currentStatus); - $stmt->execute(); - $result = $stmt->get_result(); + $allTransitions = $this->getAllTransitions(); - $transitions = []; - while ($row = $result->fetch_assoc()) { - $transitions[] = $row; + if (!isset($allTransitions[$currentStatus])) { + return []; } - $stmt->close(); - return $transitions; + return array_values($allTransitions[$currentStatus]); } /** @@ -47,22 +74,15 @@ class WorkflowModel { return true; } - $sql = "SELECT requires_admin FROM status_transitions - WHERE from_status = ? AND to_status = ? AND is_active = TRUE"; - $stmt = $this->conn->prepare($sql); - $stmt->bind_param("ss", $fromStatus, $toStatus); - $stmt->execute(); - $result = $stmt->get_result(); + $allTransitions = $this->getAllTransitions(); - if ($result->num_rows === 0) { - $stmt->close(); + if (!isset($allTransitions[$fromStatus][$toStatus])) { return false; // Transition not defined } - $row = $result->fetch_assoc(); - $stmt->close(); + $transition = $allTransitions[$fromStatus][$toStatus]; - if ($row['requires_admin'] && !$isAdmin) { + if ($transition['requires_admin'] && !$isAdmin) { return false; // Admin required } @@ -75,18 +95,20 @@ class WorkflowModel { * @return array Array of unique status values */ public function getAllStatuses() { - $sql = "SELECT DISTINCT from_status as status FROM status_transitions - UNION - SELECT DISTINCT to_status as status FROM status_transitions - ORDER BY status"; - $result = $this->conn->query($sql); + return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function() { + $sql = "SELECT DISTINCT from_status as status FROM status_transitions + UNION + SELECT DISTINCT to_status as status FROM status_transitions + ORDER BY status"; + $result = $this->conn->query($sql); - $statuses = []; - while ($row = $result->fetch_assoc()) { - $statuses[] = $row['status']; - } + $statuses = []; + while ($row = $result->fetch_assoc()) { + $statuses[] = $row['status']; + } - return $statuses; + return $statuses; + }, self::$CACHE_TTL); } /** @@ -97,21 +119,23 @@ class WorkflowModel { * @return array|null Transition requirements or null if not found */ public function getTransitionRequirements($fromStatus, $toStatus) { - $sql = "SELECT requires_comment, requires_admin - FROM status_transitions - WHERE from_status = ? AND to_status = ? AND is_active = TRUE"; - $stmt = $this->conn->prepare($sql); - $stmt->bind_param("ss", $fromStatus, $toStatus); - $stmt->execute(); - $result = $stmt->get_result(); + $allTransitions = $this->getAllTransitions(); - if ($result->num_rows === 0) { - $stmt->close(); + if (!isset($allTransitions[$fromStatus][$toStatus])) { return null; } - $row = $result->fetch_assoc(); - $stmt->close(); - return $row; + $transition = $allTransitions[$fromStatus][$toStatus]; + return [ + 'requires_comment' => $transition['requires_comment'], + 'requires_admin' => $transition['requires_admin'] + ]; + } + + /** + * Clear workflow cache (call when transitions are modified) + */ + public static function clearCache() { + CacheHelper::delete(self::$CACHE_PREFIX); } }