9a8940b9d0
PHP 8.2 strict mysqli mode throws mysqli_sql_exception on duplicate key rather than returning false from execute(). Replace the old if/else errno check with try/catch on mysqli_sql_exception, re-throw non-1062 errors, and use random_int range 100000000-999999999 (no leading zeros) for the retry ID. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
820 lines
31 KiB
PHP
820 lines
31 KiB
PHP
<?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,
|
|
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 = ?";
|
|
$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;
|
|
|
|
// Build WHERE clause
|
|
$whereConditions = [];
|
|
$params = [];
|
|
$paramTypes = '';
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
|
|
// 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 = ?";
|
|
}
|
|
|
|
$stmt = $this->conn->prepare($sql);
|
|
if (!$stmt) {
|
|
return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false];
|
|
}
|
|
|
|
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']
|
|
);
|
|
}
|
|
|
|
$result = $stmt->execute();
|
|
$affectedRows = $stmt->affected_rows;
|
|
$stmt->close();
|
|
|
|
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");
|
|
}
|
|
|
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, assigned_to, visibility, visibility_groups)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
|
|
|
$stmt = $this->conn->prepare($sql);
|
|
|
|
// 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;
|
|
}
|
|
|
|
$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
|
|
];
|
|
}
|
|
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
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
$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 {
|
|
$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;
|
|
}
|
|
} |