allow to delete comment in a ticket

This commit is contained in:
Yinyin Liu 2026-04-30 11:03:50 +02:00
parent 6f1d35016c
commit 53f0363da6
8 changed files with 152 additions and 12 deletions

View File

@ -2501,6 +2501,42 @@ public class Controller : ControllerBase
return comment;
}
[HttpDelete(nameof(DeleteTicketComment))]
public async Task<ActionResult> 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<Object> GetTicketDetail(Int64 id, Token authToken)
{

View File

@ -36,6 +36,7 @@ public static partial class Db
// Ticket system tables
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
public static TableQuery<TicketComment> TicketComments => Connection.Table<TicketComment>();
public static TableQuery<TicketCommentMention> TicketCommentMentions => Connection.Table<TicketCommentMention>();
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();

View File

@ -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<String> GetS3KeysForTicketDocuments(Int64 ticketId)
{
// Get documents attached directly to the ticket

View File

@ -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<number | null>(null);
const [editBody, setEditBody] = useState('');
const [savingEdit, setSavingEdit] = useState(false);
const [deleteCandidateId, setDeleteCandidateId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
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({
</Typography>
)}
{canEdit && !isEditing && (
<Button
size="small"
variant="text"
sx={{ minWidth: 0, p: 0, ml: 'auto', textTransform: 'none' }}
onClick={() => startEdit(comment)}
>
<FormattedMessage id="edit" defaultMessage="Edit" />
</Button>
<Box sx={{ ml: 'auto', display: 'flex', gap: 0.5 }}>
<Tooltip title={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })}>
<IconButton
size="small"
onClick={() => startEdit(comment)}
aria-label={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={intl.formatMessage({ id: 'delete', defaultMessage: 'Delete' })}>
<IconButton
size="small"
color="error"
onClick={() => setDeleteCandidateId(comment.id)}
disabled={deletingId === comment.id}
aria-label={intl.formatMessage({ id: 'delete', defaultMessage: 'Delete' })}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
{isEditing ? (
@ -422,6 +464,38 @@ function CommentThread({
{uploading && <LinearProgress />}
</Box>
</CardContent>
<Dialog
open={deleteCandidateId !== null}
onClose={() => deletingId === null && setDeleteCandidateId(null)}
>
<DialogTitle>
<FormattedMessage id="deleteComment" defaultMessage="Delete comment" />
</DialogTitle>
<DialogContent>
<DialogContentText>
<FormattedMessage
id="deleteCommentConfirm"
defaultMessage="This will permanently delete the comment and any attachments. Continue?"
/>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteCandidateId(null)}
disabled={deletingId !== null}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
<Button
onClick={confirmDelete}
color="error"
variant="contained"
disabled={deletingId !== null}
>
<FormattedMessage id="delete" defaultMessage="Delete" />
</Button>
</DialogActions>
</Dialog>
</Card>
);
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",