diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 19fcb4d3c..ab756e82f 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2176,6 +2176,17 @@ public class Controller : ControllerBase CreatedAt = DateTime.UtcNow }); + if (ticket.AssigneeId.HasValue && ticket.AssigneeId.Value != user.Id) + { + var assignee = Db.GetUserById(ticket.AssigneeId); + if (assignee is not null) + _ = Task.Run(async () => + { + try { await assignee.SendTicketAssignedEmail(ticket); } + catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email on create: {ex}"); } + }); + } + // Fire-and-forget AI diagnosis var lang = user.Language ?? "en"; TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning(); @@ -2221,6 +2232,40 @@ public class Controller : ControllerBase ActorId = user.Id, CreatedAt = DateTime.UtcNow }); + + var isSolveTransition = ticket.Status == (Int32)TicketStatus.Resolved + && existing.Status != (Int32)TicketStatus.Resolved; + var isReopenTransition = existing.Status == (Int32)TicketStatus.Resolved + && (ticket.Status == (Int32)TicketStatus.InProgress + || ticket.Status == (Int32)TicketStatus.Open); + + if (isSolveTransition) + { + var creator = Db.GetUserById(existing.CreatedByUserId); + if (creator is not null && creator.Id != user.Id) + { + var actorName = user.Name; + _ = Task.Run(async () => + { + try { await creator.SendTicketSolvedEmail(ticket, actorName); } + catch (Exception ex) { Console.WriteLine($"Failed to send ticket solved email: {ex}"); } + }); + } + } + + if (isReopenTransition && existing.AssigneeId.HasValue) + { + var assignee = Db.GetUserById(existing.AssigneeId); + if (assignee is not null && assignee.Id != user.Id) + { + var actorName = user.Name; + _ = Task.Run(async () => + { + try { await assignee.SendTicketReopenedEmail(ticket, actorName); } + catch (Exception ex) { Console.WriteLine($"Failed to send ticket reopened email: {ex}"); } + }); + } + } } if (resolutionAdded) @@ -2253,7 +2298,7 @@ public class Controller : ControllerBase CreatedAt = DateTime.UtcNow }); - if (assignee is not null) + if (assignee is not null && assignee.Id != user.Id) _ = Task.Run(async () => { try { await assignee.SendTicketAssignedEmail(ticket); } @@ -2321,6 +2366,35 @@ public class Controller : ControllerBase ticket.UpdatedAt = DateTime.UtcNow; Db.Update(ticket); + var mentioned = (comment.MentionedUserIds ?? new List()) + .Distinct() + .Where(uid => uid != user.Id) + .ToList(); + + foreach (var uid in mentioned) + { + Db.Create(new TicketCommentMention + { + CommentId = comment.Id, + MentionedUserId = uid, + CreatedAt = DateTime.UtcNow + }); + + var mentionedUser = Db.GetUserById(uid); + if (mentionedUser is null) continue; + + var actorName = user.Name; + var body = comment.Body ?? ""; + var excerpt = body.Length > 300 ? body.Substring(0, 300) + "..." : body; + var ticketRef = ticket; + + _ = Task.Run(async () => + { + try { await mentionedUser.SendTicketMentionEmail(ticketRef, actorName, excerpt); } + catch (Exception ex) { Console.WriteLine($"Failed to send mention email: {ex}"); } + }); + } + return comment; } diff --git a/csharp/App/Backend/DataTypes/Methods/User.cs b/csharp/App/Backend/DataTypes/Methods/User.cs index 6eb13cbaa..8dd43ce5e 100644 --- a/csharp/App/Backend/DataTypes/Methods/User.cs +++ b/csharp/App/Backend/DataTypes/Methods/User.cs @@ -359,4 +359,163 @@ public static class UserMethods return user.SendEmail(subject, body); } + public static Task SendTicketSolvedEmail(this User user, Ticket ticket, String solvedByName) + { + var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + $"inesco energy – Ticket #{ticket.Id} wurde gelöst", + $"Sehr geehrte/r {user.Name},\n\n" + + $"Ihr Ticket wurde als gelöst markiert von {solvedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Betreff: {ticket.Subject}\n\n" + + $"Ursache:\n{ticket.RootCause}\n\n" + + $"Lösung:\n{ticket.Solution}\n\n" + + $"Falls das Problem weiterhin besteht, öffnen Sie das Ticket erneut: {ticketLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + $"inesco energy – Le ticket #{ticket.Id} a été résolu", + $"Cher/Chère {user.Name},\n\n" + + $"Votre ticket a été marqué comme résolu par {solvedByName} :\n\n" + + $"Ticket : #{ticket.Id}\n" + + $"Objet : {ticket.Subject}\n\n" + + $"Cause :\n{ticket.RootCause}\n\n" + + $"Solution :\n{ticket.Solution}\n\n" + + $"Si le problème persiste, rouvrez le ticket : {ticketLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + $"inesco energy – Il ticket #{ticket.Id} è stato risolto", + $"Gentile {user.Name},\n\n" + + $"Il suo ticket è stato contrassegnato come risolto da {solvedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Oggetto: {ticket.Subject}\n\n" + + $"Causa:\n{ticket.RootCause}\n\n" + + $"Soluzione:\n{ticket.Solution}\n\n" + + $"Se il problema persiste, riaprire il ticket: {ticketLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + $"inesco energy – Ticket #{ticket.Id} has been solved", + $"Dear {user.Name},\n\n" + + $"Your ticket has been marked as solved by {solvedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Subject: {ticket.Subject}\n\n" + + $"Root cause:\n{ticket.RootCause}\n\n" + + $"Solution:\n{ticket.Solution}\n\n" + + $"If the issue persists, reopen the ticket: {ticketLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + + public static Task SendTicketReopenedEmail(this User user, Ticket ticket, String reopenedByName) + { + var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; + var priority = (TicketPriority)ticket.Priority; + var category = (TicketCategory)ticket.Category; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + $"inesco energy – Ticket #{ticket.Id} wurde wieder geöffnet", + $"Sehr geehrte/r {user.Name},\n\n" + + $"Ein Ihnen zugewiesenes Ticket wurde von {reopenedByName} wieder geöffnet:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Betreff: {ticket.Subject}\n" + + $"Priorität: {priority}\n" + + $"Kategorie: {category}\n\n" + + $"Öffnen Sie das Ticket hier: {ticketLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + $"inesco energy – Le ticket #{ticket.Id} a été rouvert", + $"Cher/Chère {user.Name},\n\n" + + $"Un ticket qui vous est attribué a été rouvert par {reopenedByName} :\n\n" + + $"Ticket : #{ticket.Id}\n" + + $"Objet : {ticket.Subject}\n" + + $"Priorité : {priority}\n" + + $"Catégorie : {category}\n\n" + + $"Ouvrir le ticket : {ticketLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + $"inesco energy – Il ticket #{ticket.Id} è stato riaperto", + $"Gentile {user.Name},\n\n" + + $"Un ticket assegnato a lei è stato riaperto da {reopenedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Oggetto: {ticket.Subject}\n" + + $"Priorità: {priority}\n" + + $"Categoria: {category}\n\n" + + $"Aprire il ticket: {ticketLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + $"inesco energy – Ticket #{ticket.Id} has been reopened", + $"Dear {user.Name},\n\n" + + $"A ticket assigned to you has been reopened by {reopenedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Subject: {ticket.Subject}\n" + + $"Priority: {priority}\n" + + $"Category: {category}\n\n" + + $"Open the ticket: {ticketLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + + public static Task SendTicketMentionEmail(this User user, Ticket ticket, String mentionedByName, String commentExcerpt) + { + var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + $"inesco energy – Sie wurden in Ticket #{ticket.Id} erwähnt", + $"Sehr geehrte/r {user.Name},\n\n" + + $"{mentionedByName} hat Sie in einem Kommentar zu Ticket #{ticket.Id} erwähnt:\n\n" + + $"Betreff: {ticket.Subject}\n\n" + + $"Kommentar:\n\"{commentExcerpt}\"\n\n" + + $"Ticket öffnen: {ticketLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + $"inesco energy – Vous avez été mentionné dans le ticket #{ticket.Id}", + $"Cher/Chère {user.Name},\n\n" + + $"{mentionedByName} vous a mentionné dans un commentaire sur le ticket #{ticket.Id} :\n\n" + + $"Objet : {ticket.Subject}\n\n" + + $"Commentaire :\n« {commentExcerpt} »\n\n" + + $"Ouvrir le ticket : {ticketLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + $"inesco energy – È stato menzionato nel ticket #{ticket.Id}", + $"Gentile {user.Name},\n\n" + + $"{mentionedByName} l'ha menzionata in un commento sul ticket #{ticket.Id}:\n\n" + + $"Oggetto: {ticket.Subject}\n\n" + + $"Commento:\n\"{commentExcerpt}\"\n\n" + + $"Aprire il ticket: {ticketLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + $"inesco energy – You were mentioned in ticket #{ticket.Id}", + $"Dear {user.Name},\n\n" + + $"{mentionedByName} mentioned you in a comment on ticket #{ticket.Id}:\n\n" + + $"Subject: {ticket.Subject}\n\n" + + $"Comment:\n\"{commentExcerpt}\"\n\n" + + $"Open the ticket: {ticketLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + } \ No newline at end of file diff --git a/csharp/App/Backend/DataTypes/TicketComment.cs b/csharp/App/Backend/DataTypes/TicketComment.cs index a026712b3..df0df19a6 100644 --- a/csharp/App/Backend/DataTypes/TicketComment.cs +++ b/csharp/App/Backend/DataTypes/TicketComment.cs @@ -13,4 +13,6 @@ public class TicketComment public Int64? AuthorId { get; set; } public String Body { get; set; } = ""; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Ignore] public List MentionedUserIds { get; set; } = new(); } diff --git a/csharp/App/Backend/DataTypes/TicketCommentMention.cs b/csharp/App/Backend/DataTypes/TicketCommentMention.cs new file mode 100644 index 000000000..ecb150abd --- /dev/null +++ b/csharp/App/Backend/DataTypes/TicketCommentMention.cs @@ -0,0 +1,12 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public class TicketCommentMention +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 CommentId { get; set; } + [Indexed] public Int64 MentionedUserId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index c677ae8c0..a6759de3a 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -87,6 +87,7 @@ public static partial class Db public static Boolean Create(TicketComment comment) => Insert(comment); public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis); public static Boolean Create(TicketTimelineEvent ev) => Insert(ev); + public static Boolean Create(TicketCommentMention mention) => Insert(mention); // Document storage public static Boolean Create(Document document) => Insert(document); diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index fe5a8770c..f909b67eb 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -143,6 +143,7 @@ public static partial class Db // Ticket system tables fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 792038882..6bc3ccb64 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -7,15 +7,20 @@ import { CardContent, CardHeader, Chip, + ClickAwayListener, Divider, LinearProgress, + MenuItem, + MenuList, + Paper, + Popper, TextField, 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 { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; import DocumentList from 'src/components/DocumentList'; @@ -33,6 +38,7 @@ function CommentThread({ onCommentAdded, adminUsers = [] }: CommentThreadProps) { + const intl = useIntl(); const [body, setBody] = useState(''); const [submitting, setSubmitting] = useState(false); const fileInputRef = useRef(null); @@ -40,6 +46,68 @@ function CommentThread({ const [uploading, setUploading] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const [mentionedIds, setMentionedIds] = useState([]); + const [mentionQuery, setMentionQuery] = useState(null); + const commentInputRef = useRef(null); + + const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin']; + + const mentionCandidates = mentionQuery === null + ? [] + : adminUsers + .filter((u) => + !MENTION_EXCLUDED_NAMES.includes(u.name) && + u.name.toLowerCase().includes(mentionQuery.toLowerCase()) && + !mentionedIds.includes(u.id) + ) + .slice(0, 8); + + const detectMention = (text: string, cursor: number) => { + const upToCursor = text.slice(0, cursor); + const atIdx = upToCursor.lastIndexOf('@'); + if (atIdx === -1) return null; + const between = upToCursor.slice(atIdx + 1); + if (/\s/.test(between)) return null; + const prevChar = atIdx === 0 ? ' ' : upToCursor[atIdx - 1]; + if (!/\s|^$/.test(prevChar) && atIdx !== 0) return null; + return { atIdx, query: between }; + }; + + const handleBodyChange = (e: React.ChangeEvent) => { + const text = e.target.value; + const cursor = e.target.selectionStart ?? text.length; + setBody(text); + const match = detectMention(text, cursor); + setMentionQuery(match ? match.query : null); + + // Drop mentioned IDs whose display names no longer appear in the body + setMentionedIds((prev) => + prev.filter((uid) => { + const u = adminUsers.find((au) => au.id === uid); + return u ? text.includes(`@${u.name}`) : false; + }) + ); + }; + + const handleSelectMention = (userId: number, userName: string) => { + const input = commentInputRef.current; + const cursor = input?.selectionStart ?? body.length; + const match = detectMention(body, cursor); + if (!match) return; + const before = body.slice(0, match.atIdx); + const after = body.slice(cursor); + const token = `@${userName} `; + const next = `${before}${token}${after}`; + setBody(next); + setMentionedIds((prev) => (prev.includes(userId) ? prev : [...prev, userId])); + setMentionQuery(null); + const caret = before.length + token.length; + setTimeout(() => { + input?.focus(); + input?.setSelectionRange(caret, caret); + }, 0); + }; + const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; const MAX_FILE_SIZE = 25 * 1024 * 1024; @@ -64,7 +132,15 @@ function CommentThread({ try { let commentId: number | undefined; if (body.trim()) { - const res = await axiosConfig.post('/AddTicketComment', { ticketId, body }); + const activeMentionedIds = mentionedIds.filter((uid) => { + const u = adminUsers.find((au) => au.id === uid); + return u ? body.includes(`@${u.name}`) : false; + }); + const res = await axiosConfig.post('/AddTicketComment', { + ticketId, + body, + mentionedUserIds: activeMentionedIds + }); commentId = res.data?.id; } @@ -90,6 +166,8 @@ function CommentThread({ } setBody(''); + setMentionedIds([]); + setMentionQuery(null); setSelectedFiles([]); setRefreshKey((k) => k + 1); onCommentAdded(); @@ -166,10 +244,35 @@ function CommentThread({ multiline minRows={2} maxRows={4} - placeholder="Add a comment..." + placeholder={intl.formatMessage({ + id: 'mentionPlaceholder', + defaultMessage: 'Type @ to mention a user' + })} value={body} - onChange={(e) => setBody(e.target.value)} + onChange={handleBodyChange} + inputRef={commentInputRef} /> + 0} + anchorEl={commentInputRef.current} + placement="top-start" + style={{ zIndex: 1300 }} + > + setMentionQuery(null)}> + + + {mentionCandidates.map((u) => ( + handleSelectMention(u.id, u.name)} + > + {u.name} + + ))} + + + + = { [TicketStatus.Open]: 'Open', [TicketStatus.InProgress]: 'In Progress', [TicketStatus.Escalated]: 'Escalated', - [TicketStatus.Resolved]: 'Resolved', + [TicketStatus.Resolved]: 'Solved', [TicketStatus.Closed]: 'Closed' }; diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index bb00c7e02..9422415cb 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, @@ -63,9 +63,7 @@ const priorityKeys: Record = { const statusKeys: { value: number; id: string; defaultMessage: string }[] = [ { value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' }, { value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' }, - { value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' }, - { value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' }, - { value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' } + { value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved' } ]; function TicketDetailPage() { @@ -90,6 +88,9 @@ function TicketDetailPage() { const [savingDescription, setSavingDescription] = useState(false); const [descriptionSaved, setDescriptionSaved] = useState(false); const [docRefreshKey, setDocRefreshKey] = useState(0); + const [solveGateOpen, setSolveGateOpen] = useState(false); + const rootCauseRef = useRef(null); + const solutionRef = useRef(null); // Custom "Other" editing state const [editCustomSub, setEditCustomSub] = useState(''); @@ -153,9 +154,7 @@ function TicketDetailPage() { newStatus === TicketStatus.Resolved && (!rootCause.trim() || !solution.trim()) ) { - setResolutionError( - 'Root Cause and Solution are required to resolve a ticket.' - ); + setSolveGateOpen(true); return; } setResolutionError(''); @@ -475,6 +474,7 @@ function TicketDetailPage() { error={ !!resolutionError && !rootCause.trim() } + inputRef={rootCauseRef} /> {resolutionSaved && ( @@ -877,6 +878,36 @@ function TicketDetailPage() { + {/* Solve-gate reminder dialog */} + setSolveGateOpen(false)}> + + + + + + + + + + + + +