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 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 10:53:26 -05:00
parent 1101558fca
commit d2a8c73e2c
6 changed files with 504 additions and 91 deletions

View File

@@ -12,6 +12,7 @@ RateLimitMiddleware::apply('api');
try { try {
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication (session already started by RateLimitMiddleware) // Check authentication (session already started by RateLimitMiddleware)
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
@@ -20,22 +21,10 @@ try {
exit; 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'); header('Content-Type: application/json');
// Get all users for mentions/assignment // Get all users for mentions/assignment
$sql = "SELECT user_id, username, display_name FROM users ORDER BY display_name, username"; $result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
$result = $conn->query($sql);
if (!$result) { if (!$result) {
throw new Exception("Failed to query users"); throw new Exception("Failed to query users");
@@ -52,8 +41,6 @@ try {
echo json_encode(['success' => true, 'users' => $users]); echo json_encode(['success' => true, 'users' => $users]);
$conn->close();
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);

View File

@@ -8,6 +8,7 @@ ini_set('display_errors', 0);
error_reporting(E_ALL); error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
RateLimitMiddleware::apply('api'); RateLimitMiddleware::apply('api');
try { try {
@@ -91,6 +92,7 @@ try {
); );
if ($stmt->execute()) { if ($stmt->execute()) {
WorkflowModel::clearCache(); // Clear workflow cache
echo json_encode(['success' => true, 'transition_id' => $conn->insert_id]); echo json_encode(['success' => true, 'transition_id' => $conn->insert_id]);
} else { } else {
echo json_encode(['success' => false, 'error' => $stmt->error]); echo json_encode(['success' => false, 'error' => $stmt->error]);
@@ -118,7 +120,11 @@ try {
$id $id
); );
echo json_encode(['success' => $stmt->execute()]); $success = $stmt->execute();
if ($success) {
WorkflowModel::clearCache(); // Clear workflow cache
}
echo json_encode(['success' => $success]);
$stmt->close(); $stmt->close();
break; break;
@@ -130,7 +136,11 @@ try {
$stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?"); $stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
$stmt->bind_param('i', $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(); $stmt->close();
break; break;

191
helpers/CacheHelper.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
/**
* Simple File-Based Cache Helper
*
* Provides caching for frequently accessed data that doesn't change often,
* such as workflow rules, user preferences, and configuration data.
*/
class CacheHelper {
private static $cacheDir = null;
private static $memoryCache = [];
/**
* Get the cache directory path
*
* @return string Cache directory path
*/
private static function getCacheDir() {
if (self::$cacheDir === null) {
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
if (!is_dir(self::$cacheDir)) {
mkdir(self::$cacheDir, 0755, true);
}
}
return self::$cacheDir;
}
/**
* Generate a cache key from components
*
* @param string $prefix Cache prefix (e.g., 'workflow', 'user_prefs')
* @param mixed $identifier Unique identifier
* @return string Cache key
*/
private static function makeKey($prefix, $identifier = null) {
$key = $prefix;
if ($identifier !== null) {
$key .= '_' . md5(serialize($identifier));
}
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
}
/**
* Get cached data
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
* @return mixed|null Cached data or null if not found/expired
*/
public static function get($prefix, $identifier = null, $ttl = 300) {
$key = self::makeKey($prefix, $identifier);
// Check memory cache first (fastest)
if (isset(self::$memoryCache[$key])) {
$cached = self::$memoryCache[$key];
if (time() - $cached['time'] < $ttl) {
return $cached['data'];
}
unset(self::$memoryCache[$key]);
}
// Check file cache
$filePath = self::getCacheDir() . '/' . $key . '.json';
if (file_exists($filePath)) {
$content = @file_get_contents($filePath);
if ($content !== false) {
$cached = json_decode($content, true);
if ($cached && isset($cached['time']) && isset($cached['data'])) {
if (time() - $cached['time'] < $ttl) {
// Store in memory cache for faster subsequent access
self::$memoryCache[$key] = $cached;
return $cached['data'];
}
}
}
// Expired - delete file
@unlink($filePath);
}
return null;
}
/**
* Store data in cache
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier
* @param mixed $data Data to cache
* @return bool Success
*/
public static function set($prefix, $identifier, $data) {
$key = self::makeKey($prefix, $identifier);
$cached = [
'time' => 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);
}
}
}
}

174
helpers/Database.php Normal file
View File

@@ -0,0 +1,174 @@
<?php
/**
* Database Connection Factory
*
* Centralizes database connection creation and management.
* Provides a singleton connection for the request lifecycle.
*/
class Database {
private static $connection = null;
/**
* Get database connection (singleton pattern)
*
* @return mysqli Database connection
* @throws Exception If connection fails
*/
public static function getConnection() {
if (self::$connection === null) {
self::$connection = self::createConnection();
}
// Check if connection is still alive
if (!self::$connection->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);
}
}

View File

@@ -1,22 +1,26 @@
<?php <?php
/** /**
* UserPreferencesModel * UserPreferencesModel
* Handles user-specific preferences and settings * Handles user-specific preferences and settings with caching
*/ */
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
class UserPreferencesModel { class UserPreferencesModel {
private $conn; private $conn;
private static $CACHE_PREFIX = 'user_prefs';
private static $CACHE_TTL = 300; // 5 minutes
public function __construct($conn) { public function __construct($conn) {
$this->conn = $conn; $this->conn = $conn;
} }
/** /**
* Get all preferences for a user * Get all preferences for a user (with caching)
* @param int $userId User ID * @param int $userId User ID
* @return array Associative array of preference_key => preference_value * @return array Associative array of preference_key => preference_value
*/ */
public function getUserPreferences($userId) { public function getUserPreferences($userId) {
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function() use ($userId) {
$sql = "SELECT preference_key, preference_value $sql = "SELECT preference_key, preference_value
FROM user_preferences FROM user_preferences
WHERE user_id = ?"; WHERE user_id = ?";
@@ -29,7 +33,9 @@ class UserPreferencesModel {
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
$prefs[$row['preference_key']] = $row['preference_value']; $prefs[$row['preference_key']] = $row['preference_value'];
} }
$stmt->close();
return $prefs; return $prefs;
}, self::$CACHE_TTL);
} }
/** /**
@@ -45,7 +51,15 @@ class UserPreferencesModel {
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)"; ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("iss", $userId, $key, $value); $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 * @return mixed Preference value or default
*/ */
public function getPreference($userId, $key, $default = null) { public function getPreference($userId, $key, $default = null) {
$sql = "SELECT preference_value FROM user_preferences $prefs = $this->getUserPreferences($userId);
WHERE user_id = ? AND preference_key = ?"; return $prefs[$key] ?? $default;
$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;
} }
/** /**
@@ -80,7 +85,15 @@ class UserPreferencesModel {
WHERE user_id = ? AND preference_key = ?"; WHERE user_id = ? AND preference_key = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("is", $userId, $key); $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 = ?"; $sql = "DELETE FROM user_preferences WHERE user_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId); $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);
} }
} }
?>

View File

@@ -1,14 +1,49 @@
<?php <?php
/** /**
* WorkflowModel - Handles status transition workflows and validation * WorkflowModel - Handles status transition workflows and validation
*
* Uses caching for frequently accessed transition rules since they rarely change.
*/ */
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
class WorkflowModel { class WorkflowModel {
private $conn; private $conn;
private static $CACHE_PREFIX = 'workflow';
private static $CACHE_TTL = 600; // 10 minutes
public function __construct($conn) { public function __construct($conn) {
$this->conn = $conn; $this->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 * Get allowed status transitions for a given status
* *
@@ -16,21 +51,13 @@ class WorkflowModel {
* @return array Array of allowed transitions with requirements * @return array Array of allowed transitions with requirements
*/ */
public function getAllowedTransitions($currentStatus) { public function getAllowedTransitions($currentStatus) {
$sql = "SELECT to_status, requires_comment, requires_admin $allTransitions = $this->getAllTransitions();
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();
$transitions = []; if (!isset($allTransitions[$currentStatus])) {
while ($row = $result->fetch_assoc()) { return [];
$transitions[] = $row;
} }
$stmt->close(); return array_values($allTransitions[$currentStatus]);
return $transitions;
} }
/** /**
@@ -47,22 +74,15 @@ class WorkflowModel {
return true; return true;
} }
$sql = "SELECT requires_admin FROM status_transitions $allTransitions = $this->getAllTransitions();
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();
if ($result->num_rows === 0) { if (!isset($allTransitions[$fromStatus][$toStatus])) {
$stmt->close();
return false; // Transition not defined return false; // Transition not defined
} }
$row = $result->fetch_assoc(); $transition = $allTransitions[$fromStatus][$toStatus];
$stmt->close();
if ($row['requires_admin'] && !$isAdmin) { if ($transition['requires_admin'] && !$isAdmin) {
return false; // Admin required return false; // Admin required
} }
@@ -75,6 +95,7 @@ class WorkflowModel {
* @return array Array of unique status values * @return array Array of unique status values
*/ */
public function getAllStatuses() { public function getAllStatuses() {
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function() {
$sql = "SELECT DISTINCT from_status as status FROM status_transitions $sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION UNION
SELECT DISTINCT to_status as status FROM status_transitions SELECT DISTINCT to_status as status FROM status_transitions
@@ -87,6 +108,7 @@ class WorkflowModel {
} }
return $statuses; return $statuses;
}, self::$CACHE_TTL);
} }
/** /**
@@ -97,21 +119,23 @@ class WorkflowModel {
* @return array|null Transition requirements or null if not found * @return array|null Transition requirements or null if not found
*/ */
public function getTransitionRequirements($fromStatus, $toStatus) { public function getTransitionRequirements($fromStatus, $toStatus) {
$sql = "SELECT requires_comment, requires_admin $allTransitions = $this->getAllTransitions();
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();
if ($result->num_rows === 0) { if (!isset($allTransitions[$fromStatus][$toStatus])) {
$stmt->close();
return null; return null;
} }
$row = $result->fetch_assoc(); $transition = $allTransitions[$fromStatus][$toStatus];
$stmt->close(); return [
return $row; '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);
} }
} }