getVisibilityFilter() (dashboard list/stats) matched via FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', '')) — stripping spaces from the column but not from the bound group name — while canUserAccessTicket() (single-ticket access) did a plain trim with no space-stripping at all. For a group name containing a space (e.g. "IT Support"), a member could open an internal ticket directly by URL but never see it in their dashboard list or stats counts. Now strips spaces from the bound parameter too, matching the column- side normalization, so both paths agree. Verified against real MariaDB: a ticket visible via canUserAccessTicket() for a space-containing group is now also matched by getVisibilityFilter()'s SQL, a wrong-group user is denied by both, and the plain no-space case is unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
894 lines
34 KiB
PHP
894 lines
34 KiB
PHP
<?php
|
|
|
|
class TicketModel
|
|
{
|
|
private mysqli $conn;
|
|
|
|
public function __construct(mysqli $conn)
|
|
{
|
|
$this->conn = $conn;
|
|
}
|
|
|
|
public function getTicketById(string $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("s", $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.
|
|
// Use a strict emptiness check so a literal "0" search is honored.
|
|
if ($search !== null && $search !== '') {
|
|
// Strip MySQL boolean mode special chars to prevent parse errors on user input
|
|
$ftSearch = trim(preg_replace('/\s+/', ' ', preg_replace('/[+\-><()\~*"@]+/', ' ', $search)));
|
|
if ($this->hasFulltextIndex() && $ftSearch !== '') {
|
|
// MATCH...AGAINST for indexed full-text search (much faster at scale)
|
|
$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 {
|
|
// No FULLTEXT index, or the sanitized boolean query is empty (search was
|
|
// only special chars) — fall back to LIKE instead of emitting invalid
|
|
// AGAINST('*' ...) syntax.
|
|
$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 ?";
|
|
|
|
// Keep a copy of the filter params (without LIMIT/OFFSET) for the
|
|
// fallback COUNT below.
|
|
$countParams = $params;
|
|
$countParamTypes = $paramTypes;
|
|
|
|
$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();
|
|
|
|
// COUNT(*) OVER() rides on returned rows, so a page past the last row
|
|
// yields zero rows and a bogus total of 0. Fall back to a direct COUNT
|
|
// so the total/pages stay correct for stale or over-range page links.
|
|
if ($totalTickets === 0 && $offset > 0) {
|
|
$countSql = "SELECT COUNT(*) AS c
|
|
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";
|
|
$countStmt = $this->conn->prepare($countSql);
|
|
if (!empty($countParams)) {
|
|
$countStmt->bind_param($countParamTypes, ...$countParams);
|
|
}
|
|
$countStmt->execute();
|
|
$totalTickets = (int)($countStmt->get_result()->fetch_assoc()['c'] ?? 0);
|
|
$countStmt->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(
|
|
"sissssisss",
|
|
$ticketData['title'],
|
|
$ticketData['priority'],
|
|
$ticketData['status'],
|
|
$ticketData['description'],
|
|
$ticketData['category'],
|
|
$ticketData['type'],
|
|
$updatedBy,
|
|
$ticketData['status'],
|
|
$ticketData['ticket_id'],
|
|
$expectedUpdatedAt
|
|
);
|
|
} else {
|
|
$stmt->bind_param(
|
|
"sissssiss",
|
|
$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];
|
|
}
|
|
|
|
// Zero affected rows is ambiguous: the ticket may not exist, an optimistic
|
|
// lock may have failed, or the row simply matched with no column changes
|
|
// (identical resubmit). Disambiguate so we neither report a false conflict
|
|
// nor silently "succeed" on a non-existent ticket.
|
|
if ($affectedRows === 0) {
|
|
$ticket = $this->getTicketById($ticketData['ticket_id']);
|
|
if (!$ticket) {
|
|
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
|
|
}
|
|
|
|
if ($expectedUpdatedAt !== null) {
|
|
// Only a genuine concurrent modification changes updated_at. If it
|
|
// still equals the expected value the WHERE matched but nothing
|
|
// changed (e.g. identical data resubmitted within the same second),
|
|
// which is not a conflict.
|
|
if ($ticket['updated_at'] !== $expectedUpdatedAt) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'This ticket was modified by another user. Please refresh and try again.',
|
|
'conflict' => true,
|
|
'current_updated_at' => $ticket['updated_at']
|
|
];
|
|
}
|
|
}
|
|
// Ticket exists and no conflict: treat no-op update as success.
|
|
}
|
|
|
|
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(string $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(
|
|
"sssi",
|
|
$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(string $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("iis", $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(string $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("is", $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, ' ', ''))";
|
|
// Strip spaces from the bound value too, matching the REPLACE()
|
|
// applied to the column, so a group name like "IT Support" is
|
|
// normalized the same way on both sides of the comparison.
|
|
$params[] = str_replace(' ', '', $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(string $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("ssis", $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
|
|
* @param array|null &$deferredFiles When provided, attachment file paths to
|
|
* remove are appended here instead of being unlinked immediately, so a
|
|
* caller running inside a DB transaction can delete them only AFTER a
|
|
* successful commit (avoids destroying files for a rolled-back delete).
|
|
* @return bool Success status
|
|
*/
|
|
public function deleteTicket(string $ticketId, ?array &$deferredFiles = null): 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 custom_field_values 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)) {
|
|
if ($deferredFiles !== null) {
|
|
// Defer physical deletion to the caller (post-commit).
|
|
foreach ($attachmentFiles as $filename) {
|
|
$deferredFiles[] = $ticketDir . '/' . basename($filename);
|
|
}
|
|
$deferredFiles[] = $ticketDir; // dir removed last, only if empty
|
|
} else {
|
|
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;
|
|
}
|
|
}
|