Add performance, security, and reliability improvements

- Consolidate all 20 API files to use centralized Database helper
- Add optimistic locking to ticket updates to prevent concurrent conflicts
- Add caching to StatsModel (60s TTL) for dashboard performance
- Add health check endpoint (api/health.php) for monitoring
- Improve rate limit cleanup with cron script and efficient DirectoryIterator
- Enable rate limit response headers (X-RateLimit-*)
- Add audit logging for workflow transitions
- Log Discord webhook failures instead of silencing
- Fix visibility check on export_tickets.php
- Add database migration system with performance indexes
- Fix cron recurring tickets to use assignTicket method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 14:39:13 -05:00
parent c3f7593f3c
commit 7575d6a277
31 changed files with 825 additions and 398 deletions

View File

@@ -0,0 +1,48 @@
-- Migration: Add Performance Indexes
-- Run this migration to improve query performance on common operations
-- Single-column indexes for filtering
-- These support the most common WHERE clauses in getAllTickets()
-- Status filtering (very common - used in almost every query)
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
-- Category and type filtering
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
-- Priority filtering
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
-- Date-based filtering and sorting
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
-- User filtering
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
-- Visibility filtering (used in every authenticated query)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
-- Composite indexes for common query patterns
-- These are more efficient than single indexes for combined filters
-- Status + created_at (common sorting with status filter)
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
-- Assigned_to + status (for "my open tickets" queries)
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
-- Visibility + status (visibility filtering with status)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
-- ticket_comments table
-- Optimize comment retrieval by ticket
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
-- Audit log indexes (if audit_log table exists)
-- Optimize audit log queries
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);

168
migrations/migrate.php Normal file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env php
<?php
/**
* Database Migration Runner
*
* Runs SQL migration files in order. Tracks completed migrations
* to prevent re-running them.
*
* Usage:
* php migrate.php # Run all pending migrations
* php migrate.php --status # Show migration status
* php migrate.php --dry-run # Show what would be run without executing
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$dryRun = in_array('--dry-run', $argv);
$statusOnly = in_array('--status', $argv);
echo "=== Database Migration Runner ===\n\n";
try {
$conn = Database::getConnection();
} catch (Exception $e) {
echo "Error: Could not connect to database: " . $e->getMessage() . "\n";
exit(1);
}
// Create migrations tracking table if it doesn't exist
$createTable = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_filename (filename)
)";
if (!$conn->query($createTable)) {
echo "Error: Could not create migrations table: " . $conn->error . "\n";
exit(1);
}
// Get list of completed migrations
$completed = [];
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
while ($row = $result->fetch_assoc()) {
$completed[] = $row['filename'];
}
// Get list of migration files
$migrationsDir = __DIR__;
$files = glob($migrationsDir . '/*.sql');
sort($files);
if (empty($files)) {
echo "No migration files found.\n";
exit(0);
}
if ($statusOnly) {
echo "Migration Status:\n";
echo str_repeat('-', 60) . "\n";
foreach ($files as $file) {
$filename = basename($file);
$status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]';
echo sprintf(" %s %s\n", $status, $filename);
}
exit(0);
}
// Find pending migrations
$pending = [];
foreach ($files as $file) {
$filename = basename($file);
if (!in_array($filename, $completed)) {
$pending[] = $file;
}
}
if (empty($pending)) {
echo "All migrations are up to date.\n";
exit(0);
}
echo sprintf("Found %d pending migration(s):\n", count($pending));
foreach ($pending as $file) {
echo " - " . basename($file) . "\n";
}
echo "\n";
if ($dryRun) {
echo "[DRY RUN] No changes made.\n";
exit(0);
}
// Run pending migrations
$success = 0;
$failed = 0;
foreach ($pending as $file) {
$filename = basename($file);
echo "Running: $filename... ";
$sql = file_get_contents($file);
if ($sql === false) {
echo "FAILED (could not read file)\n";
$failed++;
continue;
}
// Execute migration - handle multiple statements
$conn->begin_transaction();
try {
// Split by semicolon but respect statements properly
// Note: This doesn't handle semicolons in strings, but our migrations are simple
$statements = array_filter(
array_map('trim', explode(';', $sql)),
function($stmt) {
// Remove comments and check if there's actual SQL
$cleaned = preg_replace('/--.*$/m', '', $stmt);
return !empty(trim($cleaned));
}
);
foreach ($statements as $statement) {
if (!$conn->query($statement)) {
// Some "errors" are acceptable (like "index already exists")
$error = $conn->error;
if (strpos($error, 'Duplicate key name') !== false ||
strpos($error, 'already exists') !== false) {
// Index already exists, that's fine
continue;
}
throw new Exception($error);
}
}
// Record the migration
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
$stmt->bind_param('s', $filename);
if (!$stmt->execute()) {
throw new Exception("Could not record migration: " . $conn->error);
}
$conn->commit();
echo "OK\n";
$success++;
} catch (Exception $e) {
$conn->rollback();
echo "FAILED (" . $e->getMessage() . ")\n";
$failed++;
}
}
echo "\n";
echo "=== Migration Complete ===\n";
echo sprintf(" Success: %d\n", $success);
echo sprintf(" Failed: %d\n", $failed);
exit($failed > 0 ? 1 : 0);