From 53f0363da613208ac0c3501f6dd1c099ebfe319f Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 30 Apr 2026 11:03:50 +0200 Subject: [PATCH] allow to delete comment in a ticket --- csharp/App/Backend/Controller.cs | 36 ++++++++ csharp/App/Backend/Database/Db.cs | 1 + csharp/App/Backend/Database/Delete.cs | 29 +++++- .../dashboards/Tickets/CommentThread.tsx | 90 +++++++++++++++++-- typescript/frontend-marios2/src/lang/de.json | 2 + typescript/frontend-marios2/src/lang/en.json | 2 + typescript/frontend-marios2/src/lang/fr.json | 2 + typescript/frontend-marios2/src/lang/it.json | 2 + 8 files changed, 152 insertions(+), 12 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 4d23b77ef..9cd188a97 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2501,6 +2501,42 @@ public class Controller : ControllerBase return comment; } + [HttpDelete(nameof(DeleteTicketComment))] + public async Task DeleteTicketComment(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var comment = Db.TicketComments.FirstOrDefault(c => c.Id == id); + if (comment is null) return NotFound(); + + // Author-only; AI comments cannot be deleted via this endpoint. + if (comment.AuthorType != (Int32)CommentAuthorType.Human) return Unauthorized(); + if (comment.AuthorId != user.Id) return Unauthorized(); + + // Clean up S3 objects for documents attached to this comment before DB delete. + var s3Keys = Db.Documents + .Where(d => d.TicketCommentId == comment.Id) + .Select(d => d.S3Key) + .ToList(); + if (s3Keys.Count > 0) + { + try { await DocumentBucket.DeleteObjects(s3Keys); } + catch (Exception ex) { Console.WriteLine($"[Documents] S3 cleanup on comment delete failed: {ex.Message}"); } + } + + if (!Db.Delete(comment)) return StatusCode(500, "Failed to delete comment."); + + var ticket = Db.GetTicketById(comment.TicketId); + if (ticket is not null) + { + ticket.UpdatedAt = DateTime.UtcNow; + Db.Update(ticket); + } + + return Ok(); + } + [HttpGet(nameof(GetTicketDetail))] public ActionResult GetTicketDetail(Int64 id, Token authToken) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 63d0b7f10..4bdd7b2bb 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -36,6 +36,7 @@ public static partial class Db // Ticket system tables public static TableQuery Tickets => Connection.Table(); public static TableQuery TicketComments => Connection.Table(); + public static TableQuery TicketCommentMentions => Connection.Table(); public static TableQuery TicketAiDiagnoses => Connection.Table(); public static TableQuery TicketTimelineEvents => Connection.Table(); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index 1bf82931c..a2f06a600 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -129,10 +129,13 @@ public static partial class Db .Select(t => t.Id).ToList(); foreach (var tid in ticketIds) { - // Delete documents attached to ticket comments + // Delete documents and mentions attached to ticket comments var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList(); foreach (var cid in tCommentIds) - Documents.Delete(d => d.TicketCommentId == cid); + { + Documents .Delete(d => d.TicketCommentId == cid); + TicketCommentMentions .Delete(m => m.CommentId == cid); + } // Delete documents attached directly to the ticket Documents .Delete(d => d.TicketId == tid); @@ -231,13 +234,16 @@ public static partial class Db Boolean DeleteTicketAndChildren() { - // Delete documents attached to comments on this ticket + // Delete documents and mentions attached to comments on this ticket var commentIds = TicketComments .Where(c => c.TicketId == ticket.Id) .Select(c => c.Id) .ToList(); foreach (var cid in commentIds) - Documents.Delete(d => d.TicketCommentId == cid); + { + Documents .Delete(d => d.TicketCommentId == cid); + TicketCommentMentions .Delete(m => m.CommentId == cid); + } // Delete documents attached directly to the ticket Documents .Delete(d => d.TicketId == ticket.Id); @@ -256,6 +262,21 @@ public static partial class Db return success; } + public static Boolean Delete(TicketComment comment) + { + var deleteSuccess = RunTransaction(DeleteCommentAndChildren); + if (deleteSuccess) Backup(); + return deleteSuccess; + + Boolean DeleteCommentAndChildren() + { + // Document rows attached to this comment (S3 cleanup happens in the controller) + Documents .Delete(d => d.TicketCommentId == comment.Id); + TicketCommentMentions .Delete(m => m.CommentId == comment.Id); + return TicketComments .Delete(c => c.Id == comment.Id) > 0; + } + } + public static List GetS3KeysForTicketDocuments(Int64 ticketId) { // Get documents attached directly to the ticket diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 3efd81a3f..80de4f9f9 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -9,18 +9,27 @@ import { CardHeader, Chip, ClickAwayListener, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, Divider, + IconButton, LinearProgress, MenuItem, MenuList, Paper, Popper, TextField, + Tooltip, Typography } from '@mui/material'; import PersonIcon from '@mui/icons-material/Person'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import AttachFileIcon from '@mui/icons-material/AttachFile'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; @@ -49,6 +58,8 @@ function CommentThread({ const [editingId, setEditingId] = useState(null); const [editBody, setEditBody] = useState(''); const [savingEdit, setSavingEdit] = useState(false); + const [deleteCandidateId, setDeleteCandidateId] = useState(null); + const [deletingId, setDeletingId] = useState(null); const fileInputRef = useRef(null); const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); @@ -147,6 +158,23 @@ function CommentThread({ setEditBody(''); }; + const confirmDelete = async () => { + if (deleteCandidateId == null) return; + const id = deleteCandidateId; + setDeletingId(id); + try { + await axiosConfig.delete('/DeleteTicketComment', { params: { id } }); + setDeleteCandidateId(null); + if (editingId === id) { + setEditingId(null); + setEditBody(''); + } + onCommentAdded(); + } finally { + setDeletingId(null); + } + }; + const saveEdit = async (commentId: number) => { if (!editBody.trim()) return; setSavingEdit(true); @@ -274,14 +302,28 @@ function CommentThread({ )} {canEdit && !isEditing && ( - + + + startEdit(comment)} + aria-label={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })} + > + + + + + setDeleteCandidateId(comment.id)} + disabled={deletingId === comment.id} + aria-label={intl.formatMessage({ id: 'delete', defaultMessage: 'Delete' })} + > + + + + )} {isEditing ? ( @@ -422,6 +464,38 @@ function CommentThread({ {uploading && } + deletingId === null && setDeleteCandidateId(null)} + > + + + + + + + + + + + + + ); } diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 21e66fd38..cf681f159 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -573,6 +573,8 @@ "comments": "Kommentare", "noComments": "Noch keine Kommentare.", "commentEdited": "(bearbeitet {time})", + "deleteComment": "Kommentar löschen", + "deleteCommentConfirm": "Der Kommentar und alle Anhänge werden dauerhaft gelöscht. Fortfahren?", "commentMarkdownHint": "Markdown: **fett**, #, ##", "commentFormatBold": "Fett", "commentFormatH1": "Überschrift 1", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 9f8a2d99c..059853db3 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -321,6 +321,8 @@ "comments": "Comments", "noComments": "No comments yet.", "commentEdited": "(edited {time})", + "deleteComment": "Delete comment", + "deleteCommentConfirm": "This will permanently delete the comment and any attachments. Continue?", "commentMarkdownHint": "Markdown: **bold**, #, ##", "commentFormatBold": "Bold", "commentFormatH1": "Heading 1", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 91e61e9db..f4931b4a6 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -573,6 +573,8 @@ "comments": "Commentaires", "noComments": "Aucun commentaire pour le moment.", "commentEdited": "(modifié {time})", + "deleteComment": "Supprimer le commentaire", + "deleteCommentConfirm": "Le commentaire et toutes les pièces jointes seront définitivement supprimés. Continuer ?", "commentMarkdownHint": "Markdown : **gras**, #, ##", "commentFormatBold": "Gras", "commentFormatH1": "Titre 1", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 488589cdf..38fa25903 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -573,6 +573,8 @@ "comments": "Commenti", "noComments": "Nessun commento ancora.", "commentEdited": "(modificato {time})", + "deleteComment": "Elimina commento", + "deleteCommentConfirm": "Il commento e tutti gli allegati saranno eliminati definitivamente. Continuare?", "commentMarkdownHint": "Markdown: **grassetto**, #, ##", "commentFormatBold": "Grassetto", "commentFormatH1": "Titolo 1",