Files
tinker_tickets/models/TicketModel.php
T

841 lines
31 KiB
PHP
Raw Normal View History

<?php
class TicketModel
{
private mysqli $conn;
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
public function getTicketById(int $id): ?array
{
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_updated.username as updater_username,
2026-01-01 18:36:34 -05:00
u_updated.display_name as updater_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
2026-01-01 18:36:34 -05:00
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
WHERE t.ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
return null;
}
return $result->fetch_assoc();
}
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = [], ?array $user = null): array
{
// Calculate offset
$offset = ($page - 1) * $limit;
2026-01-09 11:20:27 -05:00
// Build WHERE clause
$whereConditions = [];
$params = [];
$paramTypes = '';
2026-01-09 11:20:27 -05:00
// Visibility filtering
if ($user !== null) {
$visFilter = $this->getVisibilityFilter($user);
if ($visFilter['sql'] !== '1=1') {
$whereConditions[] = $visFilter['sql'];
$params = array_merge($params, $visFilter['params']);
$paramTypes .= $visFilter['types'];
}
}
// Status filtering
if ($status) {
$statuses = explode(',', $status);
$placeholders = str_repeat('?,', count($statuses) - 1) . '?';
$whereConditions[] = "status IN ($placeholders)";
$params = array_merge($params, $statuses);
$paramTypes .= str_repeat('s', count($statuses));
}
2026-01-09 11:20:27 -05:00
// Category filtering
if ($category) {
$categories = explode(',', $category);
$placeholders = str_repeat('?,', count($categories) - 1) . '?';
$whereConditions[] = "category IN ($placeholders)";
$params = array_merge($params, $categories);
$paramTypes .= str_repeat('s', count($categories));
}
2026-01-09 11:20:27 -05:00
// Type filtering
if ($type) {
$types = explode(',', $type);
$placeholders = str_repeat('?,', count($types) - 1) . '?';
$whereConditions[] = "type IN ($placeholders)";
$params = array_merge($params, $types);
$paramTypes .= str_repeat('s', count($types));
}
2026-01-09 11:20:27 -05:00
// Search Functionality — use FULLTEXT when available, fall back to LIKE
if ($search && !empty($search)) {
if ($this->hasFulltextIndex()) {
// MATCH...AGAINST for indexed full-text search (much faster at scale)
// Strip MySQL boolean mode special chars to prevent parse errors on user input
$ftSearch = preg_replace('/[+\-><()\~*"@]+/', ' ', $search);
$ftSearch = trim(preg_replace('/\s+/', ' ', $ftSearch)) . '*';
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'ssss';
} else {
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'sssss';
}
}
2026-01-09 11:20:27 -05:00
// Advanced search filters
// Date range - created_at
if (!empty($filters['created_from'])) {
$whereConditions[] = "DATE(t.created_at) >= ?";
$params[] = $filters['created_from'];
$paramTypes .= 's';
}
if (!empty($filters['created_to'])) {
$whereConditions[] = "DATE(t.created_at) <= ?";
$params[] = $filters['created_to'];
$paramTypes .= 's';
}
// Date range - updated_at
if (!empty($filters['updated_from'])) {
$whereConditions[] = "DATE(t.updated_at) >= ?";
$params[] = $filters['updated_from'];
$paramTypes .= 's';
}
if (!empty($filters['updated_to'])) {
$whereConditions[] = "DATE(t.updated_at) <= ?";
$params[] = $filters['updated_to'];
$paramTypes .= 's';
}
// Date range - closed_at
if (!empty($filters['closed_from'])) {
$whereConditions[] = "DATE(t.closed_at) >= ?";
$params[] = $filters['closed_from'];
$paramTypes .= 's';
}
if (!empty($filters['closed_to'])) {
$whereConditions[] = "DATE(t.closed_at) <= ?";
$params[] = $filters['closed_to'];
$paramTypes .= 's';
}
2026-01-09 11:20:27 -05:00
// Priority range
if (!empty($filters['priority_min'])) {
$whereConditions[] = "t.priority >= ?";
$params[] = (int)$filters['priority_min'];
$paramTypes .= 'i';
}
if (!empty($filters['priority_max'])) {
$whereConditions[] = "t.priority <= ?";
$params[] = (int)$filters['priority_max'];
$paramTypes .= 'i';
}
// Created by user
if (!empty($filters['created_by'])) {
$whereConditions[] = "t.created_by = ?";
$params[] = (int)$filters['created_by'];
$paramTypes .= 'i';
}
// Assigned to user (including unassigned option)
if (!empty($filters['assigned_to'])) {
if ($filters['assigned_to'] === 'unassigned') {
$whereConditions[] = "t.assigned_to IS NULL";
} else {
$whereConditions[] = "t.assigned_to = ?";
$params[] = (int)$filters['assigned_to'];
$paramTypes .= 'i';
}
}
$whereClause = '';
if (!empty($whereConditions)) {
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
}
// Validate sort column to prevent SQL injection
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at', 'created_by', 'assigned_to'];
if (!in_array($sortColumn, $allowedColumns)) {
$sortColumn = 'ticket_id';
}
// Map column names to actual sort expressions
// For user columns, sort by display name with NULL handling for unassigned
$sortExpression = $sortColumn;
if ($sortColumn === 'created_by') {
$sortExpression = "COALESCE(u_created.display_name, u_created.username, 'System')";
} elseif ($sortColumn === 'assigned_to') {
// Put unassigned (NULL) at the end regardless of sort direction
$sortExpression = "CASE WHEN t.assigned_to IS NULL THEN 1 ELSE 0 END, COALESCE(u_assigned.display_name, u_assigned.username)";
} else {
$sortExpression = "t.$sortColumn";
}
// Validate sort direction
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
// Single query: use COUNT(*) OVER() window function to get total + page in one pass
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name,
COUNT(*) OVER() as _total_count
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
$whereClause
ORDER BY $sortExpression $sortDirection
LIMIT ? OFFSET ?";
$params[] = $limit;
$params[] = $offset;
$paramTypes .= 'ii';
$stmt = $this->conn->prepare($sql);
if (!empty($params)) {
$stmt->bind_param($paramTypes, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
$tickets = [];
$totalTickets = 0;
while ($row = $result->fetch_assoc()) {
$totalTickets = (int)$row['_total_count'];
unset($row['_total_count']);
$tickets[] = $row;
}
$stmt->close();
return [
'tickets' => $tickets,
'total' => $totalTickets,
'pages' => $totalTickets > 0 ? ceil($totalTickets / $limit) : 0,
'current_page' => $page
];
}
/**
* Update a ticket with optional optimistic locking
*
* @param array $ticketData Ticket data including ticket_id
* @param int|null $updatedBy User ID performing the update
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
*/
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array
{
// closed_at: set on close (preserve if already set), clear on reopen
$closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END";
// Build query with optional optimistic locking
if ($expectedUpdatedAt !== null) {
// Optimistic locking enabled - check that updated_at hasn't changed
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_by = ?,
updated_at = NOW(),
$closedAtClause
WHERE ticket_id = ? AND updated_at = ?";
} else {
// No optimistic locking
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_by = ?,
updated_at = NOW(),
$closedAtClause
WHERE ticket_id = ?";
}
2026-01-01 15:40:32 -05:00
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false];
}
2026-01-01 15:40:32 -05:00
if ($expectedUpdatedAt !== null) {
$stmt->bind_param(
"sissssisis",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['status'],
$ticketData['ticket_id'],
$expectedUpdatedAt
);
} else {
$stmt->bind_param(
"sissssisi",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['status'],
$ticketData['ticket_id']
);
}
2026-01-01 15:40:32 -05:00
$result = $stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close();
2026-01-01 15:40:32 -05:00
if (!$result) {
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
}
// Check for optimistic locking conflict
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
// Either ticket doesn't exist or was modified by someone else
$ticket = $this->getTicketById($ticketData['ticket_id']);
if ($ticket) {
return [
'success' => false,
'error' => 'This ticket was modified by another user. Please refresh and try again.',
'conflict' => true,
'current_updated_at' => $ticket['updated_at']
];
} else {
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
}
}
return ['success' => true, 'error' => null, 'conflict' => false];
}
public function createTicket(array $ticketData, ?int $createdBy = null): array
{
// Generate unique ticket ID (9-digit format with leading zeros)
// Uses cryptographically secure random numbers for better distribution
// Includes exponential backoff and fallback for reliability under high load
$maxAttempts = 50;
$attempts = 0;
$ticket_id = null;
do {
// Use random_int for cryptographically secure random number
try {
$candidate_id = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) {
// Fallback to mt_rand if random_int fails (shouldn't happen)
$candidate_id = sprintf('%09d', mt_rand(100000000, 999999999));
}
// Check if this ID already exists
$checkSql = "SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1";
$checkStmt = $this->conn->prepare($checkSql);
$checkStmt->bind_param("s", $candidate_id);
$checkStmt->execute();
$checkResult = $checkStmt->get_result();
if ($checkResult->num_rows === 0) {
$ticket_id = $candidate_id;
}
$checkStmt->close();
$attempts++;
// Exponential backoff: sleep longer as attempts increase
// This helps reduce contention under high load
if ($ticket_id === null && $attempts < $maxAttempts) {
usleep(min($attempts * 1000, 10000)); // Max 10ms delay
}
} while ($ticket_id === null && $attempts < $maxAttempts);
// Fallback: use timestamp-based ID if random generation fails
if ($ticket_id === null) {
// Generate ID from timestamp + random suffix for uniqueness
$timestamp = (int)(microtime(true) * 1000) % 1000000000;
$ticket_id = sprintf('%09d', $timestamp);
// Verify this fallback ID is unique
$checkStmt = $this->conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
$checkStmt->bind_param("s", $ticket_id);
$checkStmt->execute();
if ($checkStmt->get_result()->num_rows > 0) {
$checkStmt->close();
error_log("Ticket ID generation failed after {$maxAttempts} attempts + fallback");
return [
'success' => false,
'error' => 'Failed to generate unique ticket ID. Please try again.'
];
}
$checkStmt->close();
error_log("Ticket ID generation used fallback after {$maxAttempts} random attempts");
}
2026-01-01 15:40:32 -05:00
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, assigned_to, visibility, visibility_groups)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
2026-01-01 15:40:32 -05:00
$stmt = $this->conn->prepare($sql);
2026-01-01 15:40:32 -05:00
// Set default values if not provided
$status = $ticketData['status'] ?? 'Open';
$priority = $ticketData['priority'] ?? '4';
$category = $ticketData['category'] ?? 'General';
$type = $ticketData['type'] ?? 'Issue';
$visibility = $ticketData['visibility'] ?? 'public';
$visibilityGroups = $ticketData['visibility_groups'] ?? null;
$assignedTo = !empty($ticketData['assigned_to']) ? (int)$ticketData['assigned_to'] : null;
// Validate visibility
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
$visibility = 'public';
}
// Validate internal visibility requires groups
if ($visibility === 'internal') {
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified'
];
}
} else {
// Clear visibility_groups if not internal
$visibilityGroups = null;
}
2026-01-01 15:40:32 -05:00
$stmt->bind_param(
"sssssssiiss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
$status,
$priority,
$category,
2026-01-01 15:40:32 -05:00
$type,
$createdBy,
$assignedTo,
$visibility,
$visibilityGroups
);
2026-01-01 15:40:32 -05:00
try {
if ($stmt->execute()) {
return [
'success' => true,
'ticket_id' => $ticket_id
];
}
return ['success' => false, 'error' => $this->conn->error];
} catch (mysqli_sql_exception $e) {
// Handle duplicate key (errno 1062) caused by race condition between
// the uniqueness SELECT above and this INSERT — regenerate and retry once
if ($e->getCode() !== 1062) {
throw $e;
}
$stmt->close();
try {
$ticket_id = (string)random_int(100000000, 999999999);
} catch (Exception $ex) {
$ticket_id = (string)mt_rand(100000000, 999999999);
}
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sssssssiiss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
$status,
$priority,
$category,
$type,
$createdBy,
$assignedTo,
$visibility,
$visibilityGroups
);
try {
if ($stmt->execute()) {
return ['success' => true, 'ticket_id' => $ticket_id];
}
} catch (mysqli_sql_exception $e2) {
// Second attempt also hit duplicate — extremely rare
}
return ['success' => false, 'error' => 'Failed to create ticket due to ID collision'];
}
}
public function addComment(int $ticketId, array $commentData): array
{
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
// Set default username
$username = $commentData['user_name'] ?? 'User';
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
$stmt->bind_param(
"issi",
$ticketId,
$username,
$commentData['comment_text'],
$markdownEnabled
);
if ($stmt->execute()) {
return [
'success' => true,
'user_name' => $username,
'created_at' => date('M d, Y H:i')
];
} else {
return [
'success' => false,
'error' => $this->conn->error
];
}
}
2026-01-01 18:36:34 -05:00
/**
* Assign ticket to a user
*
* @param int $ticketId Ticket ID
* @param int $userId User ID to assign to
* @param int $assignedBy User ID performing the assignment
* @return bool Success status
*/
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool
{
2026-01-01 18:36:34 -05:00
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Unassign ticket (set assigned_to to NULL)
*
* @param int $ticketId Ticket ID
* @param int $updatedBy User ID performing the unassignment
* @return bool Success status
*/
public function unassignTicket(int $ticketId, int $updatedBy): bool
{
2026-01-01 18:36:34 -05:00
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Get multiple tickets by IDs in a single query (batch loading)
* Eliminates N+1 query problem in bulk operations
*
* @param array $ticketIds Array of ticket IDs
* @return array Associative array keyed by ticket_id
*/
public function getTicketsByIds(array $ticketIds): array
{
if (empty($ticketIds)) {
return [];
}
// Sanitize ticket IDs: cast to string to preserve leading zeros
$ticketIds = array_map('strval', $ticketIds);
// Create placeholders for IN clause
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_updated.username as updater_username,
u_updated.display_name as updater_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
WHERE t.ticket_id IN ($placeholders)";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($ticketIds));
$stmt->bind_param($types, ...$ticketIds);
$stmt->execute();
$result = $stmt->get_result();
$tickets = [];
while ($row = $result->fetch_assoc()) {
$tickets[$row['ticket_id']] = $row;
}
$stmt->close();
return $tickets;
}
/**
* Check if a user can access a ticket based on visibility settings
*
* @param array $ticket The ticket data
* @param array $user The user data (must include user_id, is_admin, groups)
* @return bool True if user can access the ticket
*/
public function canUserAccessTicket(array $ticket, array $user): bool
{
// Admins can access all tickets
if (!empty($user['is_admin'])) {
return true;
}
$visibility = $ticket['visibility'] ?? 'public';
// Public tickets are accessible to all authenticated users
if ($visibility === 'public') {
return true;
}
// Confidential tickets: only creator, assignee, and admins
if ($visibility === 'confidential') {
$userId = $user['user_id'] ?? null;
return ((int)$ticket['created_by'] === (int)$userId || (int)$ticket['assigned_to'] === (int)$userId);
}
// Internal tickets: check if user is in any of the allowed groups
if ($visibility === 'internal') {
$allowedGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
if (empty($allowedGroups)) {
return false; // No groups specified means no access
}
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
// Check if any user group matches any allowed group
return !empty(array_intersect($userGroups, $allowedGroups));
}
return false;
}
/**
* Build visibility filter SQL for queries
*
* @param array $user The current user
* @return array ['sql' => string, 'params' => array, 'types' => string]
*/
public function getVisibilityFilter(array $user): array
{
// Admins see all tickets
if (!empty($user['is_admin'])) {
return ['sql' => '1=1', 'params' => [], 'types' => ''];
}
$userId = $user['user_id'] ?? 0;
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
// Build the visibility filter
// 1. Public tickets
// 2. Confidential tickets where user is creator or assignee
// 3. Internal tickets where user's groups overlap with visibility_groups
$conditions = [];
$params = [];
$types = '';
// Public visibility
$conditions[] = "(t.visibility = 'public' OR t.visibility IS NULL)";
// Confidential - user is creator or assignee
$conditions[] = "(t.visibility = 'confidential' AND (t.created_by = ? OR t.assigned_to = ?))";
$params[] = $userId;
$params[] = $userId;
$types .= 'ii';
// Internal - check group membership
if (!empty($userGroups)) {
$groupConditions = [];
foreach ($userGroups as $group) {
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
$params[] = $group;
$types .= 's';
}
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
}
return [
'sql' => '(' . implode(' OR ', $conditions) . ')',
'params' => $params,
'types' => $types
];
}
/**
* Update ticket visibility settings
*
* @param int $ticketId
* @param string $visibility ('public', 'internal', 'confidential')
* @param string|null $visibilityGroups Comma-separated group names for 'internal' visibility
* @param int $updatedBy User ID
* @return bool
*/
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
{
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
$visibility = 'public';
}
// Validate internal visibility requires groups
if ($visibility === 'internal') {
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
return false; // Internal visibility requires groups
}
} else {
// Clear visibility_groups if not internal
$visibilityGroups = null;
}
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Delete a ticket and all its associated records.
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
*
* @param string $ticketId Ticket ID
* @return bool Success status
*/
public function deleteTicket(string $ticketId): bool
{
// Collect attachment filenames before deleting DB rows
$attachmentFiles = [];
$attStmt = $this->conn->prepare("SELECT filename FROM ticket_attachments WHERE ticket_id = ?");
if ($attStmt) {
$attStmt->bind_param('s', $ticketId);
$attStmt->execute();
$attResult = $attStmt->get_result();
while ($row = $attResult->fetch_assoc()) {
$attachmentFiles[] = $row['filename'];
}
$attStmt->close();
}
// Delete child records first to avoid FK constraint failures
$children = [
"DELETE FROM ticket_comments WHERE ticket_id = ?",
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
"DELETE FROM ticket_custom_fields WHERE ticket_id = ?",
];
foreach ($children as $sql) {
try {
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
continue;
}
// ticket_dependencies uses two placeholders
if (strpos($sql, 'depends_on_id') !== false) {
$stmt->bind_param('ss', $ticketId, $ticketId);
} else {
$stmt->bind_param('s', $ticketId);
}
$stmt->execute();
$stmt->close();
} catch (mysqli_sql_exception $e) {
// Skip optional tables that may not exist in all deployments
if (strpos($e->getMessage(), "doesn't exist") === false) {
throw $e;
}
}
}
$stmt = $this->conn->prepare("DELETE FROM tickets WHERE ticket_id = ?");
if (!$stmt) {
return false;
}
$stmt->bind_param('s', $ticketId);
$result = $stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
if ($result && $affected > 0) {
// Clean up physical attachment files
$uploadDir = defined('UPLOAD_DIR')
? UPLOAD_DIR
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
if (is_dir($ticketDir)) {
foreach ($attachmentFiles as $filename) {
$file = $ticketDir . '/' . basename($filename);
if (file_exists($file)) {
@unlink($file);
}
}
@rmdir($ticketDir); // Remove dir only if empty
}
return true;
}
return false;
}
/**
* Check whether the FULLTEXT index on tickets(title, description) exists.
* Result is cached for the process lifetime (static).
*/
private function hasFulltextIndex(): bool
{
static $result = null;
if ($result !== null) {
return $result;
}
$r = $this->conn->query(
"SELECT COUNT(*) as cnt FROM information_schema.STATISTICS
WHERE table_schema = DATABASE()
AND table_name = 'tickets'
AND index_type = 'FULLTEXT'
AND index_name = 'ft_title_description'"
);
$result = $r && (int)$r->fetch_assoc()['cnt'] > 0;
return $result;
}
}