Implement comprehensive improvement plan (Phases 1-6)

Security (Phase 1-2):
- Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc.
- Add RateLimitMiddleware for API rate limiting
- Add security event logging to AuditLogModel
- Add ResponseHelper for standardized API responses
- Update config.php with security constants

Database (Phase 3):
- Add migration 014 for additional indexes
- Add migration 015 for ticket dependencies
- Add migration 016 for ticket attachments
- Add migration 017 for recurring tickets
- Add migration 018 for custom fields

Features (Phase 4-5):
- Add ticket dependencies with DependencyModel and API
- Add duplicate detection with check_duplicates API
- Add file attachments with AttachmentModel and upload/download APIs
- Add @mentions with autocomplete and highlighting
- Add quick actions on dashboard rows

Collaboration (Phase 5):
- Add mention extraction in CommentModel
- Add mention autocomplete dropdown in ticket.js
- Add mention highlighting CSS styles

Admin & Export (Phase 6):
- Add StatsModel for dashboard widgets
- Add dashboard stats cards (open, critical, unassigned, etc.)
- Add CSV/JSON export via export_tickets API
- Add rich text editor toolbar in markdown.js
- Add RecurringTicketModel with cron job
- Add CustomFieldModel for per-category fields
- Add admin views: RecurringTickets, CustomFields, Workflow,
  Templates, AuditLog, UserActivity
- Add admin APIs: manage_workflows, manage_templates,
  manage_recurring, custom_fields, get_users
- Add admin routes in index.php

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 09:55:01 -05:00
parent 8c7211d311
commit be505b7312
53 changed files with 6640 additions and 169 deletions

212
models/AttachmentModel.php Normal file
View File

@@ -0,0 +1,212 @@
<?php
/**
* AttachmentModel - Handles ticket file attachments
*/
require_once __DIR__ . '/../config/config.php';
class AttachmentModel {
private $conn;
public function __construct() {
$this->conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($this->conn->connect_error) {
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
}
}
/**
* Get all attachments for a ticket
*/
public function getAttachments($ticketId) {
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
LEFT JOIN users u ON a.uploaded_by = u.user_id
WHERE a.ticket_id = ?
ORDER BY a.uploaded_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$attachments = [];
while ($row = $result->fetch_assoc()) {
$attachments[] = $row;
}
$stmt->close();
return $attachments;
}
/**
* Get a single attachment by ID
*/
public function getAttachment($attachmentId) {
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
LEFT JOIN users u ON a.uploaded_by = u.user_id
WHERE a.attachment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $attachmentId);
$stmt->execute();
$result = $stmt->get_result();
$attachment = $result->fetch_assoc();
$stmt->close();
return $attachment;
}
/**
* Add a new attachment record
*/
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) {
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute();
if ($result) {
$attachmentId = $this->conn->insert_id;
$stmt->close();
return $attachmentId;
}
$stmt->close();
return false;
}
/**
* Delete an attachment record
*/
public function deleteAttachment($attachmentId) {
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $attachmentId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Get total attachment size for a ticket
*/
public function getTotalSizeForTicket($ticketId) {
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
FROM ticket_attachments
WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
return (int)$row['total_size'];
}
/**
* Get attachment count for a ticket
*/
public function getAttachmentCount($ticketId) {
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
return (int)$row['count'];
}
/**
* Check if user can delete attachment (owner or admin)
*/
public function canUserDelete($attachmentId, $userId, $isAdmin = false) {
if ($isAdmin) {
return true;
}
$attachment = $this->getAttachment($attachmentId);
return $attachment && $attachment['uploaded_by'] == $userId;
}
/**
* Format file size for display
*/
public static function formatFileSize($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' bytes';
}
}
/**
* Get file icon based on mime type
*/
public static function getFileIcon($mimeType) {
if (strpos($mimeType, 'image/') === 0) {
return '🖼️';
} elseif (strpos($mimeType, 'video/') === 0) {
return '🎬';
} elseif (strpos($mimeType, 'audio/') === 0) {
return '🎵';
} elseif ($mimeType === 'application/pdf') {
return '📄';
} elseif (strpos($mimeType, 'text/') === 0) {
return '📝';
} elseif (in_array($mimeType, ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip'])) {
return '📦';
} elseif (in_array($mimeType, ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])) {
return '📘';
} elseif (in_array($mimeType, ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
return '📊';
} else {
return '📎';
}
}
/**
* Validate file type against allowed types
*/
public static function isAllowedType($mimeType) {
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'text/plain', 'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
'application/json', 'application/xml'
];
return in_array($mimeType, $allowedTypes);
}
public function __destruct() {
if ($this->conn) {
$this->conn->close();
}
}
}

View File

@@ -290,6 +290,110 @@ class AuditLogModel {
return $this->log($userId, 'view', 'ticket', $ticketId);
}
// ========================================
// Security Event Logging Methods
// ========================================
/**
* Log a security event
*
* @param string $eventType Type of security event
* @param array $details Additional details
* @param int|null $userId User ID if known
* @return bool Success status
*/
public function logSecurityEvent($eventType, $details = [], $userId = null) {
$details['event_type'] = $eventType;
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
return $this->log($userId, 'security_event', 'security', null, $details);
}
/**
* Log a failed authentication attempt
*
* @param string $username Username attempted
* @param string $reason Reason for failure
* @return bool Success status
*/
public function logFailedAuth($username, $reason = 'Invalid credentials') {
return $this->logSecurityEvent('failed_auth', [
'username' => $username,
'reason' => $reason
]);
}
/**
* Log a CSRF token failure
*
* @param string $endpoint The endpoint that was accessed
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logCsrfFailure($endpoint, $userId = null) {
return $this->logSecurityEvent('csrf_failure', [
'endpoint' => $endpoint,
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
], $userId);
}
/**
* Log a rate limit exceeded event
*
* @param string $endpoint The endpoint that was rate limited
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logRateLimitExceeded($endpoint, $userId = null) {
return $this->logSecurityEvent('rate_limit_exceeded', [
'endpoint' => $endpoint
], $userId);
}
/**
* Log an unauthorized access attempt
*
* @param string $resource The resource that was accessed
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logUnauthorizedAccess($resource, $userId = null) {
return $this->logSecurityEvent('unauthorized_access', [
'resource' => $resource
], $userId);
}
/**
* Get security events (for admin review)
*
* @param int $limit Maximum number of events
* @param int $offset Offset for pagination
* @return array Security events
*/
public function getSecurityEvents($limit = 100, $offset = 0) {
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type = 'security_event'
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?"
);
$stmt->bind_param("ii", $limit, $offset);
$stmt->execute();
$result = $stmt->get_result();
$events = [];
while ($row = $result->fetch_assoc()) {
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$events[] = $row;
}
$stmt->close();
return $events;
}
/**
* Get formatted timeline for a specific ticket
* Includes all ticket updates and comments

View File

@@ -1,10 +1,54 @@
<?php
class CommentModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Extract @mentions from comment text
*
* @param string $text Comment text
* @return array Array of mentioned usernames
*/
public function extractMentions($text) {
$mentions = [];
// Match @username patterns (alphanumeric, underscores, hyphens)
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
$mentions = array_unique($matches[1]);
}
return $mentions;
}
/**
* Get user IDs for mentioned usernames
*
* @param array $usernames Array of usernames
* @return array Array of user records with user_id, username, display_name
*/
public function getMentionedUsers($usernames) {
if (empty($usernames)) {
return [];
}
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($usernames));
$stmt->bind_param($types, ...$usernames);
$stmt->execute();
$result = $stmt->get_result();
$users = [];
while ($row = $result->fetch_assoc()) {
$users[] = $row;
}
$stmt->close();
return $users;
}
public function getCommentsByTicketId($ticketId) {
$sql = "SELECT tc.*, u.display_name, u.username

230
models/CustomFieldModel.php Normal file
View File

@@ -0,0 +1,230 @@
<?php
/**
* CustomFieldModel - Manages custom field definitions and values
*/
class CustomFieldModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
// ========================================
// Field Definitions
// ========================================
/**
* Get all field definitions
*/
public function getAllDefinitions($category = null, $activeOnly = true) {
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
$params = [];
$types = '';
if ($activeOnly) {
$sql .= " AND is_active = 1";
}
if ($category !== null) {
$sql .= " AND (category = ? OR category IS NULL)";
$params[] = $category;
$types .= 's';
}
$sql .= " ORDER BY display_order ASC, field_id ASC";
if (!empty($params)) {
$stmt = $this->conn->prepare($sql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $this->conn->query($sql);
}
$fields = [];
while ($row = $result->fetch_assoc()) {
if ($row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
$fields[] = $row;
}
if (isset($stmt)) {
$stmt->close();
}
return $fields;
}
/**
* Get a single field definition
*/
public function getDefinition($fieldId) {
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $fieldId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
if ($row && $row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
return $row;
}
/**
* Create a new field definition
*/
public function createDefinition($data) {
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
}
$sql = "INSERT INTO custom_field_definitions
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sssssiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
$options,
$data['category'],
$data['is_required'] ?? 0,
$data['display_order'] ?? 0,
$data['is_active'] ?? 1
);
if ($stmt->execute()) {
$id = $this->conn->insert_id;
$stmt->close();
return ['success' => true, 'field_id' => $id];
}
$error = $stmt->error;
$stmt->close();
return ['success' => false, 'error' => $error];
}
/**
* Update a field definition
*/
public function updateDefinition($fieldId, $data) {
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
}
$sql = "UPDATE custom_field_definitions SET
field_name = ?, field_label = ?, field_type = ?, field_options = ?,
category = ?, is_required = ?, display_order = ?, is_active = ?
WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sssssiiiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
$options,
$data['category'],
$data['is_required'] ?? 0,
$data['display_order'] ?? 0,
$data['is_active'] ?? 1,
$fieldId
);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Delete a field definition
*/
public function deleteDefinition($fieldId) {
// This will cascade delete all values due to FK constraint
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $fieldId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
// ========================================
// Field Values
// ========================================
/**
* Get all field values for a ticket
*/
public function getValuesForTicket($ticketId) {
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
FROM custom_field_values cfv
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
WHERE cfv.ticket_id = ?
ORDER BY cfd.display_order ASC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$values = [];
while ($row = $result->fetch_assoc()) {
if ($row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
$values[$row['field_name']] = $row;
}
$stmt->close();
return $values;
}
/**
* Set a field value for a ticket (insert or update)
*/
public function setValue($ticketId, $fieldId, $value) {
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sis', $ticketId, $fieldId, $value);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Set multiple field values for a ticket
*/
public function setValues($ticketId, $values) {
$results = [];
foreach ($values as $fieldId => $value) {
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
}
return $results;
}
/**
* Delete all field values for a ticket
*/
public function deleteValuesForTicket($ticketId) {
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
}
?>

254
models/DependencyModel.php Normal file
View File

@@ -0,0 +1,254 @@
<?php
/**
* DependencyModel - Manages ticket dependencies
*/
class DependencyModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get all dependencies for a ticket
*
* @param string $ticketId Ticket ID
* @return array Dependencies grouped by type
*/
public function getDependencies($ticketId) {
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
JOIN tickets t ON d.depends_on_id = t.ticket_id
WHERE d.ticket_id = ?
ORDER BY d.dependency_type, d.created_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$dependencies = [
'blocks' => [],
'blocked_by' => [],
'relates_to' => [],
'duplicates' => []
];
while ($row = $result->fetch_assoc()) {
$dependencies[$row['dependency_type']][] = $row;
}
$stmt->close();
return $dependencies;
}
/**
* Get tickets that depend on this ticket
*
* @param string $ticketId Ticket ID
* @return array Dependent tickets
*/
public function getDependentTickets($ticketId) {
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
JOIN tickets t ON d.ticket_id = t.ticket_id
WHERE d.depends_on_id = ?
ORDER BY d.dependency_type, d.created_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$dependents = [];
while ($row = $result->fetch_assoc()) {
$dependents[] = $row;
}
$stmt->close();
return $dependents;
}
/**
* Add a dependency between tickets
*
* @param string $ticketId Source ticket ID
* @param string $dependsOnId Target ticket ID
* @param string $type Dependency type
* @param int $createdBy User ID who created the dependency
* @return array Result with success status
*/
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
// Validate dependency type
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
if (!in_array($type, $validTypes)) {
return ['success' => false, 'error' => 'Invalid dependency type'];
}
// Prevent self-reference
if ($ticketId === $dependsOnId) {
return ['success' => false, 'error' => 'A ticket cannot depend on itself'];
}
// Check if dependency already exists
$checkSql = "SELECT dependency_id FROM ticket_dependencies
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$checkStmt = $this->conn->prepare($checkSql);
$checkStmt->bind_param("sss", $ticketId, $dependsOnId, $type);
$checkStmt->execute();
$checkResult = $checkStmt->get_result();
if ($checkResult->num_rows > 0) {
$checkStmt->close();
return ['success' => false, 'error' => 'Dependency already exists'];
}
$checkStmt->close();
// Check for circular dependency
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
return ['success' => false, 'error' => 'This would create a circular dependency'];
}
// Insert the dependency
$sql = "INSERT INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssi", $ticketId, $dependsOnId, $type, $createdBy);
if ($stmt->execute()) {
$dependencyId = $stmt->insert_id;
$stmt->close();
return ['success' => true, 'dependency_id' => $dependencyId];
}
$error = $stmt->error;
$stmt->close();
return ['success' => false, 'error' => $error];
}
/**
* Remove a dependency
*
* @param int $dependencyId Dependency ID
* @return bool Success status
*/
public function removeDependency($dependencyId) {
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $dependencyId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Remove dependency by ticket IDs and type
*
* @param string $ticketId Source ticket ID
* @param string $dependsOnId Target ticket ID
* @param string $type Dependency type
* @return bool Success status
*/
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
$sql = "DELETE FROM ticket_dependencies
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Check if adding a dependency would create a cycle
*
* @param string $ticketId Source ticket ID
* @param string $dependsOnId Target ticket ID
* @param string $type Dependency type
* @return bool True if it would create a cycle
*/
private function wouldCreateCycle($ticketId, $dependsOnId, $type) {
// Only check for cycles in blocking relationships
if (!in_array($type, ['blocks', 'blocked_by'])) {
return false;
}
// Check if dependsOnId already has ticketId in its dependency chain
$visited = [];
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited);
}
/**
* Check if there's a dependency path from source to target
*
* @param string $source Source ticket ID
* @param string $target Target ticket ID
* @param array $visited Already visited tickets
* @return bool True if path exists
*/
private function hasDependencyPath($source, $target, &$visited) {
if ($source === $target) {
return true;
}
if (in_array($source, $visited)) {
return false;
}
$visited[] = $source;
$sql = "SELECT depends_on_id FROM ticket_dependencies
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $source);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited)) {
$stmt->close();
return true;
}
}
$stmt->close();
return false;
}
/**
* Get all dependencies for multiple tickets (batch)
*
* @param array $ticketIds Array of ticket IDs
* @return array Dependencies indexed by ticket ID
*/
public function getDependenciesBatch($ticketIds) {
if (empty($ticketIds)) {
return [];
}
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
JOIN tickets t ON d.depends_on_id = t.ticket_id
WHERE d.ticket_id IN ($placeholders)
ORDER BY d.ticket_id, d.dependency_type";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($ticketIds));
$stmt->bind_param($types, ...$ticketIds);
$stmt->execute();
$result = $stmt->get_result();
$dependencies = [];
while ($row = $result->fetch_assoc()) {
$ticketId = $row['ticket_id'];
if (!isset($dependencies[$ticketId])) {
$dependencies[$ticketId] = [];
}
$dependencies[$ticketId][] = $row;
}
$stmt->close();
return $dependencies;
}
}

View File

@@ -0,0 +1,210 @@
<?php
/**
* RecurringTicketModel - Manages recurring ticket schedules
*/
class RecurringTicketModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get all recurring tickets
*/
public function getAll($includeInactive = false) {
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
u2.display_name as creator_name, u2.username as creator_username
FROM recurring_tickets rt
LEFT JOIN users u1 ON rt.assigned_to = u1.user_id
LEFT JOIN users u2 ON rt.created_by = u2.user_id";
if (!$includeInactive) {
$sql .= " WHERE rt.is_active = 1";
}
$sql .= " ORDER BY rt.next_run_at ASC";
$result = $this->conn->query($sql);
$items = [];
while ($row = $result->fetch_assoc()) {
$items[] = $row;
}
return $items;
}
/**
* Get a single recurring ticket by ID
*/
public function getById($recurringId) {
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
return $row;
}
/**
* Create a new recurring ticket
*/
public function create($data) {
$sql = "INSERT INTO recurring_tickets
(title_template, description_template, category, type, priority, assigned_to,
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ssssiiisssis',
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['priority'],
$data['assigned_to'],
$data['schedule_type'],
$data['schedule_day'],
$data['schedule_time'],
$data['next_run_at'],
$data['is_active'],
$data['created_by']
);
if ($stmt->execute()) {
$id = $this->conn->insert_id;
$stmt->close();
return ['success' => true, 'recurring_id' => $id];
}
$error = $stmt->error;
$stmt->close();
return ['success' => false, 'error' => $error];
}
/**
* Update a recurring ticket
*/
public function update($recurringId, $data) {
$sql = "UPDATE recurring_tickets SET
title_template = ?, description_template = ?, category = ?, type = ?,
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
schedule_time = ?, next_run_at = ?, is_active = ?
WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ssssiissssii',
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['priority'],
$data['assigned_to'],
$data['schedule_type'],
$data['schedule_day'],
$data['schedule_time'],
$data['next_run_at'],
$data['is_active'],
$recurringId
);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Delete a recurring ticket
*/
public function delete($recurringId) {
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Get recurring tickets due for execution
*/
public function getDueRecurringTickets() {
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
$result = $this->conn->query($sql);
$items = [];
while ($row = $result->fetch_assoc()) {
$items[] = $row;
}
return $items;
}
/**
* Update last run and calculate next run time
*/
public function updateAfterRun($recurringId) {
$recurring = $this->getById($recurringId);
if (!$recurring) {
return false;
}
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('si', $nextRun, $recurringId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Calculate the next run time based on schedule
*/
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
$now = new DateTime();
$time = new DateTime($scheduleTime);
switch ($scheduleType) {
case 'daily':
$next = new DateTime('tomorrow ' . $scheduleTime);
break;
case 'weekly':
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $scheduleTime);
break;
case 'monthly':
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
$next = new DateTime();
$next->modify('first day of next month');
$next->setDate($next->format('Y'), $next->format('m'), $day);
$next->setTime($time->format('H'), $time->format('i'), 0);
break;
default:
$next = new DateTime('tomorrow ' . $scheduleTime);
}
return $next->format('Y-m-d H:i:s');
}
/**
* Toggle active status
*/
public function toggleActive($recurringId) {
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
}
?>

186
models/StatsModel.php Normal file
View File

@@ -0,0 +1,186 @@
<?php
/**
* StatsModel - Dashboard statistics and metrics
*
* Provides various ticket statistics for dashboard widgets
*/
class StatsModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get count of open tickets
*/
public function getOpenTicketCount() {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of closed tickets
*/
public function getClosedTicketCount() {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets grouped by priority
*/
public function getTicketsByPriority() {
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data['P' . $row['priority']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by status
*/
public function getTicketsByStatus() {
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['status']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by category
*/
public function getTicketsByCategory() {
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['category']] = (int)$row['count'];
}
return $data;
}
/**
* Get average resolution time in hours
*/
public function getAverageResolutionTime() {
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours
FROM tickets
WHERE status = 'Closed'
AND created_at IS NOT NULL
AND updated_at IS NOT NULL
AND updated_at > created_at";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
}
/**
* Get count of tickets created today
*/
public function getTicketsCreatedToday() {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets created this week
*/
public function getTicketsCreatedThisWeek() {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets closed today
*/
public function getTicketsClosedToday() {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets by assignee (top 5)
*/
public function getTicketsByAssignee($limit = 5) {
$sql = "SELECT
u.display_name,
u.username,
COUNT(t.ticket_id) as ticket_count
FROM tickets t
JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed'
GROUP BY t.assigned_to
ORDER BY ticket_count DESC
LIMIT ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $limit);
$stmt->execute();
$result = $stmt->get_result();
$data = [];
while ($row = $result->fetch_assoc()) {
$name = $row['display_name'] ?: $row['username'];
$data[$name] = (int)$row['ticket_count'];
}
$stmt->close();
return $data;
}
/**
* Get unassigned ticket count
*/
public function getUnassignedTicketCount() {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get critical (P1) ticket count
*/
public function getCriticalTicketCount() {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get all stats as a single array
*/
public function getAllStats() {
return [
'open_tickets' => $this->getOpenTicketCount(),
'closed_tickets' => $this->getClosedTicketCount(),
'created_today' => $this->getTicketsCreatedToday(),
'created_this_week' => $this->getTicketsCreatedThisWeek(),
'closed_today' => $this->getTicketsClosedToday(),
'unassigned' => $this->getUnassignedTicketCount(),
'critical' => $this->getCriticalTicketCount(),
'avg_resolution_hours' => $this->getAverageResolutionTime(),
'by_priority' => $this->getTicketsByPriority(),
'by_status' => $this->getTicketsByStatus(),
'by_category' => $this->getTicketsByCategory(),
'by_assignee' => $this->getTicketsByAssignee()
];
}
}
?>

View File

@@ -223,18 +223,6 @@ class TicketModel {
}
public function updateTicket($ticketData, $updatedBy = null) {
// Debug function
$debug = function($message, $data = null) {
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
if ($data !== null) {
$log_message .= ": " . (is_string($data) ? $data : json_encode($data));
}
$log_message .= "\n";
file_put_contents('/tmp/api_debug.log', $log_message, FILE_APPEND);
};
$debug("updateTicket called with data", $ticketData);
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
@@ -246,43 +234,27 @@ class TicketModel {
updated_at = NOW()
WHERE ticket_id = ?";
$debug("SQL query", $sql);
try {
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
$debug("Prepare statement failed", $this->conn->error);
return false;
}
$debug("Binding parameters");
$stmt->bind_param(
"sissssii",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['ticket_id']
);
$debug("Executing statement");
$result = $stmt->execute();
if (!$result) {
$debug("Execute failed", $stmt->error);
return false;
}
$debug("Update successful");
return true;
} catch (Exception $e) {
$debug("Exception", $e->getMessage());
$debug("Stack trace", $e->getTraceAsString());
throw $e;
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
return false;
}
$stmt->bind_param(
"sissssii",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['ticket_id']
);
$result = $stmt->execute();
$stmt->close();
return $result;
}
public function createTicket($ticketData, $createdBy = null) {