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; 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))] [HttpGet(nameof(GetTicketDetail))]
public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken) public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken)
{ {

View File

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

View File

@ -129,10 +129,13 @@ public static partial class Db
.Select(t => t.Id).ToList(); .Select(t => t.Id).ToList();
foreach (var tid in ticketIds) 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(); var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList();
foreach (var cid in tCommentIds) 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 // Delete documents attached directly to the ticket
Documents .Delete(d => d.TicketId == tid); Documents .Delete(d => d.TicketId == tid);
@ -231,13 +234,16 @@ public static partial class Db
Boolean DeleteTicketAndChildren() Boolean DeleteTicketAndChildren()
{ {
// Delete documents attached to comments on this ticket // Delete documents and mentions attached to comments on this ticket
var commentIds = TicketComments var commentIds = TicketComments
.Where(c => c.TicketId == ticket.Id) .Where(c => c.TicketId == ticket.Id)
.Select(c => c.Id) .Select(c => c.Id)
.ToList(); .ToList();
foreach (var cid in commentIds) 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 // Delete documents attached directly to the ticket
Documents .Delete(d => d.TicketId == ticket.Id); Documents .Delete(d => d.TicketId == ticket.Id);
@ -256,6 +262,21 @@ public static partial class Db
return success; 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) public static List<String> GetS3KeysForTicketDocuments(Int64 ticketId)
{ {
// Get documents attached directly to the ticket // Get documents attached directly to the ticket

View File

@ -9,18 +9,27 @@ import {
CardHeader, CardHeader,
Chip, Chip,
ClickAwayListener, ClickAwayListener,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider, Divider,
IconButton,
LinearProgress, LinearProgress,
MenuItem, MenuItem,
MenuList, MenuList,
Paper, Paper,
Popper, Popper,
TextField, TextField,
Tooltip,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import SmartToyIcon from '@mui/icons-material/SmartToy'; import SmartToyIcon from '@mui/icons-material/SmartToy';
import AttachFileIcon from '@mui/icons-material/AttachFile'; 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 { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
@ -49,6 +58,8 @@ function CommentThread({
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editBody, setEditBody] = useState(''); const [editBody, setEditBody] = useState('');
const [savingEdit, setSavingEdit] = useState(false); 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 fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -147,6 +158,23 @@ function CommentThread({
setEditBody(''); 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) => { const saveEdit = async (commentId: number) => {
if (!editBody.trim()) return; if (!editBody.trim()) return;
setSavingEdit(true); setSavingEdit(true);
@ -274,14 +302,28 @@ function CommentThread({
</Typography> </Typography>
)} )}
{canEdit && !isEditing && ( {canEdit && !isEditing && (
<Button <Box sx={{ ml: 'auto', display: 'flex', gap: 0.5 }}>
<Tooltip title={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })}>
<IconButton
size="small" size="small"
variant="text"
sx={{ minWidth: 0, p: 0, ml: 'auto', textTransform: 'none' }}
onClick={() => startEdit(comment)} onClick={() => startEdit(comment)}
aria-label={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })}
> >
<FormattedMessage id="edit" defaultMessage="Edit" /> <EditIcon fontSize="small" />
</Button> </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> </Box>
{isEditing ? ( {isEditing ? (
@ -422,6 +464,38 @@ function CommentThread({
{uploading && <LinearProgress />} {uploading && <LinearProgress />}
</Box> </Box>
</CardContent> </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> </Card>
); );
} }

View File

@ -573,6 +573,8 @@
"comments": "Kommentare", "comments": "Kommentare",
"noComments": "Noch keine Kommentare.", "noComments": "Noch keine Kommentare.",
"commentEdited": "(bearbeitet {time})", "commentEdited": "(bearbeitet {time})",
"deleteComment": "Kommentar löschen",
"deleteCommentConfirm": "Der Kommentar und alle Anhänge werden dauerhaft gelöscht. Fortfahren?",
"commentMarkdownHint": "Markdown: **fett**, #, ##", "commentMarkdownHint": "Markdown: **fett**, #, ##",
"commentFormatBold": "Fett", "commentFormatBold": "Fett",
"commentFormatH1": "Überschrift 1", "commentFormatH1": "Überschrift 1",

View File

@ -321,6 +321,8 @@
"comments": "Comments", "comments": "Comments",
"noComments": "No comments yet.", "noComments": "No comments yet.",
"commentEdited": "(edited {time})", "commentEdited": "(edited {time})",
"deleteComment": "Delete comment",
"deleteCommentConfirm": "This will permanently delete the comment and any attachments. Continue?",
"commentMarkdownHint": "Markdown: **bold**, #, ##", "commentMarkdownHint": "Markdown: **bold**, #, ##",
"commentFormatBold": "Bold", "commentFormatBold": "Bold",
"commentFormatH1": "Heading 1", "commentFormatH1": "Heading 1",

View File

@ -573,6 +573,8 @@
"comments": "Commentaires", "comments": "Commentaires",
"noComments": "Aucun commentaire pour le moment.", "noComments": "Aucun commentaire pour le moment.",
"commentEdited": "(modifié {time})", "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**, #, ##", "commentMarkdownHint": "Markdown : **gras**, #, ##",
"commentFormatBold": "Gras", "commentFormatBold": "Gras",
"commentFormatH1": "Titre 1", "commentFormatH1": "Titre 1",

View File

@ -573,6 +573,8 @@
"comments": "Commenti", "comments": "Commenti",
"noComments": "Nessun commento ancora.", "noComments": "Nessun commento ancora.",
"commentEdited": "(modificato {time})", "commentEdited": "(modificato {time})",
"deleteComment": "Elimina commento",
"deleteCommentConfirm": "Il commento e tutti gli allegati saranno eliminati definitivamente. Continuare?",
"commentMarkdownHint": "Markdown: **grassetto**, #, ##", "commentMarkdownHint": "Markdown: **grassetto**, #, ##",
"commentFormatBold": "Grassetto", "commentFormatBold": "Grassetto",
"commentFormatH1": "Titolo 1", "commentFormatH1": "Titolo 1",