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:
212
models/AttachmentModel.php
Normal file
212
models/AttachmentModel.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
230
models/CustomFieldModel.php
Normal 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
254
models/DependencyModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
210
models/RecurringTicketModel.php
Normal file
210
models/RecurringTicketModel.php
Normal 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
186
models/StatsModel.php
Normal 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()
|
||||
];
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user