diff --git a/Claude.md b/Claude.md index fd93c4e..69d403c 100644 --- a/Claude.md +++ b/Claude.md @@ -14,6 +14,8 @@ - Collapsible Sidebar, Kanban Card View, Inline Ticket Preview - Mobile Responsive Design, Ticket Linking in Comments - Admin Pages (Templates, Workflow, Recurring, Custom Fields, User Activity, Audit Log, API Keys) +- Comment Edit/Delete (owner or admin can modify their comments) +- Markdown Tables Support, Auto-linking URLs in Comments ## Project Overview @@ -51,6 +53,7 @@ Controllers → Models → Database │ ├── bulk_operation.php # POST: Bulk operations - admin only │ ├── check_duplicates.php # GET: Check for duplicate tickets │ ├── delete_attachment.php # POST/DELETE: Delete attachment +│ ├── delete_comment.php # POST: Delete comment (owner/admin) │ ├── export_tickets.php # GET: Export tickets to CSV/JSON │ ├── generate_api_key.php # POST: Generate API key (admin) │ ├── get_template.php # GET: Fetch ticket template @@ -60,6 +63,7 @@ Controllers → Models → Database │ ├── manage_workflows.php # CRUD: Workflow rules (admin) │ ├── revoke_api_key.php # POST: Revoke API key (admin) │ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies +│ ├── update_comment.php # POST: Update comment (owner/admin) │ ├── update_ticket.php # POST: Update ticket (workflow validation) │ └── upload_attachment.php # GET/POST: List or upload attachments ├── assets/ diff --git a/README.md b/README.md index 2e73e60..9a904e5 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,10 @@ A feature-rich PHP-based ticketing system designed for tracking and managing dat - **Comment Requirements**: Optional comment requirements for specific transitions ### Collaboration Features -- **Markdown Comments**: Full Markdown support with live preview and toolbar +- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering - **@Mentions**: Tag users in comments with autocomplete +- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments +- **Auto-linking**: URLs in comments are automatically converted to clickable links - **File Attachments**: Upload files to tickets with drag-and-drop support - **Ticket Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates - **Activity Timeline**: Complete audit trail of all ticket changes diff --git a/api/delete_comment.php b/api/delete_comment.php new file mode 100644 index 0000000..6c93914 --- /dev/null +++ b/api/delete_comment.php @@ -0,0 +1,105 @@ + false, 'error' => 'Invalid CSRF token']); + exit; + } + + $currentUser = $_SESSION['user']; + $userId = $currentUser['user_id']; + $isAdmin = $currentUser['is_admin'] ?? false; + + // Create database connection + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + // Get data - support both POST body and query params + $data = json_decode(file_get_contents('php://input'), true); + + if (!$data || !isset($data['comment_id'])) { + // Try query params + if (isset($_GET['comment_id'])) { + $data = ['comment_id' => $_GET['comment_id']]; + } else { + throw new Exception("Missing required field: comment_id"); + } + } + + $commentId = (int)$data['comment_id']; + + // Initialize models + $commentModel = new CommentModel($conn); + $auditLog = new AuditLogModel($conn); + + // Get comment before deletion for audit log + $comment = $commentModel->getCommentById($commentId); + + // Delete comment + $result = $commentModel->deleteComment($commentId, $userId, $isAdmin); + + // Log the deletion if successful + if ($result['success'] && $comment) { + $auditLog->log( + $userId, + 'delete', + 'comment', + (string)$commentId, + [ + 'ticket_id' => $comment['ticket_id'], + 'comment_text_preview' => substr($comment['comment_text'], 0, 100) + ] + ); + } + + // Discard any unexpected output + ob_end_clean(); + + header('Content-Type: application/json'); + echo json_encode($result); + +} catch (Exception $e) { + ob_end_clean(); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); +} diff --git a/api/update_comment.php b/api/update_comment.php new file mode 100644 index 0000000..65157d9 --- /dev/null +++ b/api/update_comment.php @@ -0,0 +1,102 @@ + false, 'error' => 'Invalid CSRF token']); + exit; + } + } + + $currentUser = $_SESSION['user']; + $userId = $currentUser['user_id']; + $isAdmin = $currentUser['is_admin'] ?? false; + + // Create database connection + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + // Get POST/PUT data + $data = json_decode(file_get_contents('php://input'), true); + + if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) { + throw new Exception("Missing required fields: comment_id, comment_text"); + } + + $commentId = (int)$data['comment_id']; + $commentText = trim($data['comment_text']); + $markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled']; + + if (empty($commentText)) { + throw new Exception("Comment text cannot be empty"); + } + + // Initialize models + $commentModel = new CommentModel($conn); + $auditLog = new AuditLogModel($conn); + + // Update comment + $result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin); + + // Log the update if successful + if ($result['success']) { + $auditLog->log( + $userId, + 'update', + 'comment', + (string)$commentId, + ['comment_text_preview' => substr($commentText, 0, 100)] + ); + } + + // Discard any unexpected output + ob_end_clean(); + + header('Content-Type: application/json'); + echo json_encode($result); + +} catch (Exception $e) { + ob_end_clean(); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); +} diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index 8755275..b81fc9e 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -3850,6 +3850,52 @@ table td:nth-child(4) { } } +/* ===== MOBILE-ONLY ELEMENTS - Hidden on Desktop ===== */ +.mobile-filter-toggle, +.mobile-bottom-nav, +.mobile-sidebar-close, +.mobile-sidebar-overlay { + display: none !important; +} + +/* ===== MARKDOWN TABLE STYLES ===== */ +.markdown-table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + font-family: var(--font-mono); + font-size: 0.9rem; +} + +.markdown-table th, +.markdown-table td { + border: 1px solid var(--terminal-green); + padding: 0.5rem 0.75rem; + text-align: left; +} + +.markdown-table th { + background: rgba(0, 255, 65, 0.1); + color: var(--terminal-green); + font-weight: bold; +} + +.markdown-table tr:hover td { + background: rgba(0, 255, 65, 0.05); +} + +/* Auto-linked URLs styling */ +.auto-link { + color: var(--terminal-cyan); + text-decoration: none; + word-break: break-all; +} + +.auto-link:hover { + color: var(--terminal-amber); + text-decoration: underline; +} + /* ===== MOBILE STYLES - PHONES (max 768px) ===== */ @media (max-width: 768px) { /* ===== BASE RESETS ===== */ diff --git a/assets/css/ticket.css b/assets/css/ticket.css index e87c002..dccdd5d 100644 --- a/assets/css/ticket.css +++ b/assets/css/ticket.css @@ -407,6 +407,29 @@ textarea[data-field="description"]:not(:disabled)::after { font-weight: 500; } +/* Status/Priority row for CreateTicketView - 4 columns */ +.status-priority-row { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.detail-quarter { + flex: 1 1 calc(25% - 0.75rem); + min-width: 150px; +} + +.detail-quarter label { + display: block; + margin-bottom: 0.5rem; +} + +.detail-quarter select { + width: 100%; + padding: 0.5rem; + min-height: 40px; +} + .full-width { grid-column: 1 / -1; } @@ -560,6 +583,9 @@ textarea.editable { .comment-header { display: flex; justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--terminal-green); @@ -592,6 +618,13 @@ textarea.editable { color: var(--terminal-green); } +.comment-edited { + font-size: 0.85em; + color: var(--terminal-amber); + font-style: italic; + margin-left: 0.5rem; +} + .comment-text { color: var(--terminal-green); font-family: var(--font-mono); @@ -621,6 +654,103 @@ textarea.editable { margin: 10px 0; } +/* Comment Action Buttons (Edit/Delete) */ +.comment-actions { + display: flex; + gap: 0.5rem; + margin-left: auto; +} + +.comment-action-btn { + background: transparent; + border: 1px solid var(--terminal-green); + color: var(--terminal-green); + padding: 0.25rem 0.5rem; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: var(--font-mono); + line-height: 1; +} + +.comment-action-btn:hover { + background: rgba(0, 255, 65, 0.1); +} + +.comment-action-btn.edit-btn:hover { + color: var(--terminal-amber); + border-color: var(--terminal-amber); +} + +.comment-action-btn.delete-btn:hover { + color: var(--priority-1); + border-color: var(--priority-1); +} + +/* Comment Edit Form */ +.comment.editing { + background: rgba(0, 255, 65, 0.05); + padding: 1rem; + margin: -0.5rem; + border: 1px dashed var(--terminal-amber); +} + +.comment-edit-form { + margin-top: 0.5rem; +} + +.comment-edit-textarea { + width: 100%; + min-height: 100px; + padding: 0.75rem; + background: var(--bg-secondary); + border: 2px solid var(--terminal-green); + color: var(--terminal-green); + font-family: var(--font-mono); + font-size: 0.9rem; + resize: vertical; +} + +.comment-edit-textarea:focus { + outline: none; + border-color: var(--terminal-amber); + box-shadow: 0 0 10px rgba(255, 176, 0, 0.3); +} + +.comment-edit-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.75rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.markdown-toggle-small { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--terminal-green); + cursor: pointer; +} + +.markdown-toggle-small input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.comment-edit-buttons { + display: flex; + gap: 0.5rem; +} + +.btn-small { + padding: 0.4rem 0.75rem !important; + font-size: 0.85rem !important; + min-height: auto !important; +} + /* Comment Tabs - TERMINAL STYLE */ .ticket-tabs { display: flex; @@ -1859,6 +1989,82 @@ body.dark-mode .editable { padding: 0.5rem 1rem; min-height: 44px; } + + /* Comment actions on mobile */ + .comment-header { + flex-direction: column; + align-items: flex-start; + } + + .comment-actions { + width: 100%; + justify-content: flex-end; + margin-top: 0.5rem; + } + + .comment-action-btn { + min-height: 44px; + min-width: 44px; + padding: 0.5rem; + } + + .comment-edit-controls { + flex-direction: column; + gap: 0.75rem; + } + + .comment-edit-buttons { + width: 100%; + } + + .comment-edit-buttons .btn { + flex: 1; + min-height: 44px; + } + + /* CreateTicketView - Stack metadata fields */ + .status-priority-row { + flex-direction: column !important; + gap: 1rem !important; + } + + .detail-quarter { + width: 100% !important; + flex: none !important; + } + + .detail-quarter select, + .detail-quarter input { + width: 100% !important; + min-height: 48px !important; + font-size: 16px !important; + } + + /* Form inputs in CreateTicketView */ + .detail-group input[type="text"], + .detail-group textarea, + .detail-group select { + width: 100% !important; + min-height: 48px !important; + padding: 0.75rem !important; + font-size: 16px !important; + } + + .detail-group textarea { + min-height: 150px !important; + } + + /* Visibility groups */ + .visibility-groups-list { + flex-direction: column !important; + } + + .visibility-groups-list label { + min-height: 44px; + padding: 0.5rem; + background: rgba(0, 255, 65, 0.05); + border-radius: 4px; + } } /* Extra small screens for ticket page */ diff --git a/assets/js/markdown.js b/assets/js/markdown.js index 54a20e1..b2b4061 100644 --- a/assets/js/markdown.js +++ b/assets/js/markdown.js @@ -16,11 +16,22 @@ function parseMarkdown(markdown) { // Ticket references (#123456789) - convert to clickable links html = html.replace(/#(\d{9})\b/g, '#$1'); - // Code blocks (```code```) - html = html.replace(/```([\s\S]*?)```/g, '
$1
'); + // Code blocks (```code```) - preserve content and don't process further + const codeBlocks = []; + html = html.replace(/```([\s\S]*?)```/g, function(match, code) { + codeBlocks.push('
' + code + '
'); + return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%'; + }); - // Inline code (`code`) - html = html.replace(/`([^`]+)`/g, '$1'); + // Inline code (`code`) - preserve and don't process further + const inlineCodes = []; + html = html.replace(/`([^`]+)`/g, function(match, code) { + inlineCodes.push('' + code + ''); + return '%%INLINECODE' + (inlineCodes.length - 1) + '%%'; + }); + + // Tables (must be processed before other block elements) + html = parseMarkdownTables(html); // Bold (**text** or __text__) html = html.replace(/\*\*(.+?)\*\*/g, '$1'); @@ -40,6 +51,9 @@ function parseMarkdown(markdown) { return text; }); + // Auto-link bare URLs (http, https, ftp) + html = html.replace(/(?])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '$1'); + // Headers (# H1, ## H2, etc.) html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); @@ -54,7 +68,7 @@ function parseMarkdown(markdown) { html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '
  • $1
  • '); // Blockquotes (> text) - html = html.replace(/^>\s+(.+)$/gm, '
    $1
    '); + html = html.replace(/^>\s+(.+)$/gm, '
    $1
    '); // Horizontal rules (--- or ***) html = html.replace(/^(?:---|___|\*\*\*)$/gm, '
    '); @@ -63,6 +77,14 @@ function parseMarkdown(markdown) { html = html.replace(/ \n/g, '
    '); html = html.replace(/\n\n/g, '

    '); + // Restore code blocks and inline code + codeBlocks.forEach((block, i) => { + html = html.replace('%%CODEBLOCK' + i + '%%', block); + }); + inlineCodes.forEach((code, i) => { + html = html.replace('%%INLINECODE' + i + '%%', code); + }); + // Wrap in paragraph if not already wrapped if (!html.startsWith('<')) { html = '

    ' + html + '

    '; @@ -71,6 +93,92 @@ function parseMarkdown(markdown) { return html; } +/** + * Parse markdown tables + * Supports: | Header | Header | + * |--------|--------| + * | Cell | Cell | + */ +function parseMarkdownTables(html) { + const lines = html.split('\n'); + const result = []; + let inTable = false; + let tableRows = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Check if line is a table row (starts and ends with |, or has | in the middle) + if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) { + // Check if next line is separator (|---|---|) + const nextLine = lines[i + 1] ? lines[i + 1].trim() : ''; + const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/); + + if (!inTable && !isSeparator) { + // Start of table - check if this is a header row + if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) { + inTable = true; + tableRows.push({ type: 'header', content: line }); + continue; + } + } + + if (inTable) { + if (isSeparator) { + // Skip separator line + continue; + } + tableRows.push({ type: 'body', content: line }); + continue; + } + } + + // Not a table row - flush any accumulated table + if (inTable && tableRows.length > 0) { + result.push(buildTable(tableRows)); + tableRows = []; + inTable = false; + } + result.push(lines[i]); + } + + // Flush remaining table + if (tableRows.length > 0) { + result.push(buildTable(tableRows)); + } + + return result.join('\n'); +} + +/** + * Build HTML table from parsed rows + */ +function buildTable(rows) { + if (rows.length === 0) return ''; + + let html = ''; + + rows.forEach((row, index) => { + const cells = row.content.split('|').filter(cell => cell.trim() !== ''); + const tag = row.type === 'header' ? 'th' : 'td'; + const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : ''); + + if (wrapper === 'thead') html += ''; + if (wrapper === 'tbody') html += ''; + + html += ''; + cells.forEach(cell => { + html += `<${tag}>${cell.trim()}`; + }); + html += ''; + + if (row.type === 'header') html += ''; + }); + + html += '
    '; + return html; +} + // Apply markdown rendering to all elements with data-markdown attribute function renderMarkdownElements() { document.querySelectorAll('[data-markdown]').forEach(element => { @@ -273,3 +381,39 @@ window.toolbarQuote = toolbarQuote; window.createEditorToolbar = createEditorToolbar; window.insertMarkdownFormat = insertMarkdownFormat; window.insertMarkdownText = insertMarkdownText; + +// ======================================== +// Auto-link URLs in plain text (non-markdown) +// ======================================== + +/** + * Convert plain text URLs to clickable links + * Used for non-markdown comments + */ +function autoLinkUrls(text) { + if (!text) return ''; + // Match URLs that aren't already in an href attribute + return text.replace(/(?])(https?:\/\/[^\s<>\[\]()]+)/g, + '$1'); +} + +/** + * Process all non-markdown comment elements to auto-link URLs + */ +function processPlainTextComments() { + document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => { + // Only process if not already processed + if (element.dataset.linksProcessed) return; + element.innerHTML = autoLinkUrls(element.innerHTML); + element.dataset.linksProcessed = 'true'; + }); +} + +// Run on page load +document.addEventListener('DOMContentLoaded', function() { + processPlainTextComments(); +}); + +// Expose for manual use +window.autoLinkUrls = autoLinkUrls; +window.processPlainTextComments = processPlainTextComments; diff --git a/assets/js/ticket.js b/assets/js/ticket.js index e78c593..9c070ad 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -1213,3 +1213,195 @@ document.addEventListener('DOMContentLoaded', function() { } }); }); + +// ======================================== +// Comment Edit/Delete Functions +// ======================================== + +/** + * Edit a comment + */ +function editComment(commentId) { + const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); + if (!commentDiv) return; + + const textDiv = document.getElementById(`comment-text-${commentId}`); + const rawTextarea = document.getElementById(`comment-raw-${commentId}`); + if (!textDiv || !rawTextarea) return; + + // Check if already in edit mode + if (commentDiv.classList.contains('editing')) { + cancelEditComment(commentId); + return; + } + + // Get original text and markdown setting + const originalText = rawTextarea.value; + const markdownEnabled = commentDiv.dataset.markdownEnabled === '1'; + + // Create edit form + const editForm = document.createElement('div'); + editForm.className = 'comment-edit-form'; + editForm.id = `comment-edit-form-${commentId}`; + editForm.innerHTML = ` + +
    + +
    + + +
    +
    + `; + + // Hide original text, show edit form + textDiv.style.display = 'none'; + textDiv.after(editForm); + commentDiv.classList.add('editing'); + + // Focus the textarea + document.getElementById(`comment-edit-textarea-${commentId}`).focus(); +} + +/** + * Save edited comment + */ +function saveEditComment(commentId) { + const textarea = document.getElementById(`comment-edit-textarea-${commentId}`); + const markdownCheckbox = document.getElementById(`comment-edit-markdown-${commentId}`); + + if (!textarea) return; + + const newText = textarea.value.trim(); + if (!newText) { + showToast('Comment cannot be empty', 'error'); + return; + } + + const markdownEnabled = markdownCheckbox ? markdownCheckbox.checked : false; + + // Send update request + fetch('/api/update_comment.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.CSRF_TOKEN + }, + body: JSON.stringify({ + comment_id: commentId, + comment_text: newText, + markdown_enabled: markdownEnabled + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Update the comment display + const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); + const textDiv = document.getElementById(`comment-text-${commentId}`); + const rawTextarea = document.getElementById(`comment-raw-${commentId}`); + const editForm = document.getElementById(`comment-edit-form-${commentId}`); + + // Update raw text storage + rawTextarea.value = newText; + + // Update markdown attribute + commentDiv.dataset.markdownEnabled = markdownEnabled ? '1' : '0'; + + // Update displayed text + if (markdownEnabled) { + textDiv.setAttribute('data-markdown', ''); + textDiv.textContent = newText; + // Re-render markdown + if (typeof parseMarkdown === 'function') { + textDiv.innerHTML = parseMarkdown(newText); + } + } else { + textDiv.removeAttribute('data-markdown'); + // Convert newlines to
    and highlight mentions + let displayText = escapeHtml(newText).replace(/\n/g, '
    '); + displayText = highlightMentions(displayText); + // Auto-link URLs + if (typeof autoLinkUrls === 'function') { + displayText = autoLinkUrls(displayText); + } + textDiv.innerHTML = displayText; + } + + // Remove edit form and show text + if (editForm) editForm.remove(); + textDiv.style.display = ''; + commentDiv.classList.remove('editing'); + + showToast('Comment updated successfully', 'success'); + } else { + showToast(data.error || 'Failed to update comment', 'error'); + } + }) + .catch(error => { + console.error('Error updating comment:', error); + showToast('Failed to update comment', 'error'); + }); +} + +/** + * Cancel editing a comment + */ +function cancelEditComment(commentId) { + const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); + const textDiv = document.getElementById(`comment-text-${commentId}`); + const editForm = document.getElementById(`comment-edit-form-${commentId}`); + + if (editForm) editForm.remove(); + if (textDiv) textDiv.style.display = ''; + if (commentDiv) commentDiv.classList.remove('editing'); +} + +/** + * Delete a comment + */ +function deleteComment(commentId) { + if (!confirm('Are you sure you want to delete this comment? This cannot be undone.')) { + return; + } + + fetch('/api/delete_comment.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.CSRF_TOKEN + }, + body: JSON.stringify({ + comment_id: commentId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Remove the comment from the DOM + const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); + if (commentDiv) { + commentDiv.style.transition = 'opacity 0.3s, transform 0.3s'; + commentDiv.style.opacity = '0'; + commentDiv.style.transform = 'translateX(-20px)'; + setTimeout(() => commentDiv.remove(), 300); + } + showToast('Comment deleted successfully', 'success'); + } else { + showToast(data.error || 'Failed to delete comment', 'error'); + } + }) + .catch(error => { + console.error('Error deleting comment:', error); + showToast('Failed to delete comment', 'error'); + }); +} + +// Expose functions globally +window.editComment = editComment; +window.saveEditComment = saveEditComment; +window.cancelEditComment = cancelEditComment; +window.deleteComment = deleteComment; diff --git a/index.php b/index.php index 11d8c9e..6daa351 100644 --- a/index.php +++ b/index.php @@ -82,6 +82,14 @@ switch (true) { require_once 'api/add_comment.php'; break; + case $requestPath == '/api/update_comment.php': + require_once 'api/update_comment.php'; + break; + + case $requestPath == '/api/delete_comment.php': + require_once 'api/delete_comment.php'; + break; + case $requestPath == '/api/ticket_dependencies.php': require_once 'api/ticket_dependencies.php'; break; diff --git a/models/CommentModel.php b/models/CommentModel.php index 3e1548e..03eabd1 100644 --- a/models/CommentModel.php +++ b/models/CommentModel.php @@ -115,5 +115,87 @@ class CommentModel { ]; } } + + /** + * Get a single comment by ID + */ + public function getCommentById($commentId) { + $sql = "SELECT tc.*, u.display_name, u.username + FROM ticket_comments tc + LEFT JOIN users u ON tc.user_id = u.user_id + WHERE tc.comment_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("i", $commentId); + $stmt->execute(); + $result = $stmt->get_result(); + return $result->fetch_assoc(); + } + + /** + * Update an existing comment + * Only the comment owner or an admin can update + */ + public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) { + // First check if user owns this comment or is admin + $comment = $this->getCommentById($commentId); + + if (!$comment) { + return ['success' => false, 'error' => 'Comment not found']; + } + + if ($comment['user_id'] != $userId && !$isAdmin) { + return ['success' => false, 'error' => 'You do not have permission to edit this comment']; + } + + $sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?"; + $stmt = $this->conn->prepare($sql); + $markdownInt = $markdownEnabled ? 1 : 0; + $stmt->bind_param("sii", $commentText, $markdownInt, $commentId); + + if ($stmt->execute()) { + return [ + 'success' => true, + 'comment_id' => $commentId, + 'comment_text' => $commentText, + 'markdown_enabled' => $markdownInt, + 'updated_at' => date('M d, Y H:i') + ]; + } else { + return ['success' => false, 'error' => $this->conn->error]; + } + } + + /** + * Delete a comment + * Only the comment owner or an admin can delete + */ + public function deleteComment($commentId, $userId, $isAdmin = false) { + // First check if user owns this comment or is admin + $comment = $this->getCommentById($commentId); + + if (!$comment) { + return ['success' => false, 'error' => 'Comment not found']; + } + + if ($comment['user_id'] != $userId && !$isAdmin) { + return ['success' => false, 'error' => 'You do not have permission to delete this comment']; + } + + $ticketId = $comment['ticket_id']; + + $sql = "DELETE FROM ticket_comments WHERE comment_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("i", $commentId); + + if ($stmt->execute()) { + return [ + 'success' => true, + 'comment_id' => $commentId, + 'ticket_id' => $ticketId + ]; + } else { + return ['success' => false, 'error' => $this->conn->error]; + } + } } ?> \ No newline at end of file diff --git a/scripts/add_comment_updated_at.php b/scripts/add_comment_updated_at.php new file mode 100644 index 0000000..d6d2bc2 --- /dev/null +++ b/scripts/add_comment_updated_at.php @@ -0,0 +1,45 @@ +connect_error) { + throw new Exception("Connection failed: " . $conn->connect_error); + } + + // Check if column already exists + $result = $conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'"); + + if ($result->num_rows > 0) { + echo "Column 'updated_at' already exists in ticket_comments table.\n"; + } else { + // Add the column + $sql = "ALTER TABLE ticket_comments ADD COLUMN updated_at TIMESTAMP NULL DEFAULT NULL AFTER created_at"; + + if ($conn->query($sql)) { + echo "Successfully added 'updated_at' column to ticket_comments table.\n"; + } else { + throw new Exception("Failed to add column: " . $conn->error); + } + } + + $conn->close(); + echo "Done!\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php index 430f1bc..df9d5c4 100644 --- a/views/CreateTicketView.php +++ b/views/CreateTicketView.php @@ -8,9 +8,9 @@ Create New Ticket - - - + + + - - + + - - - + + +