diff --git a/csharp/App/Backend/DataTypes/Methods/Installation.cs b/csharp/App/Backend/DataTypes/Methods/Installation.cs index fd01696cc..62ee5c66b 100644 --- a/csharp/App/Backend/DataTypes/Methods/Installation.cs +++ b/csharp/App/Backend/DataTypes/Methods/Installation.cs @@ -1,6 +1,5 @@ using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.Relations; -using InnovEnergy.Lib.Mailer; using InnovEnergy.Lib.Utils; namespace InnovEnergy.App.Backend.DataTypes.Methods; @@ -173,38 +172,4 @@ public static class InstallationMethods return true; } - private const String SupportEmail = "support@inesco.energy"; - private const String SupportName = "inesco energy Support Team"; - - public static Task SendAlarmNotificationToSupport(this Installation installation, Int32 prevStatus) - { - var productName = ProductName(installation.Product); - var fromStatus = StatusName(prevStatus); - - var subject = $"[inesco energy] Alarm: {installation.Name}"; - var body = - $"Installation \"{installation.Name}\" (ID {installation.Id}, {productName})\n" + - $"status changed from {fromStatus} to Alarm.\n\n" + - "Please check the Log tab on the Monitor to see detailed errors and warnings.\n"; - - return Mailer.Send(SupportName, SupportEmail, subject, body); - } - - private static String StatusName(Int32 status) => status switch - { - -1 => "Offline", - 0 => "Green", - 1 => "Warning", - 2 => "Alarm", - _ => "Unknown" - }; - - private static String ProductName(Int32 product) => product switch - { - 2 => "Sodistore Home", - 3 => "Sodistore Max", - 4 => "Sodistore Grid", - 5 => "Sodistore Pro", - _ => $"Product {product}" - }; } \ No newline at end of file diff --git a/csharp/App/Backend/Services/AutoTicketService.cs b/csharp/App/Backend/Services/AutoTicketService.cs new file mode 100644 index 000000000..32dce1aa4 --- /dev/null +++ b/csharp/App/Backend/Services/AutoTicketService.cs @@ -0,0 +1,246 @@ +using System.Text; +using System.Text.RegularExpressions; +using InnovEnergy.App.Backend.Database; +using InnovEnergy.App.Backend.DataTypes; +using InnovEnergy.App.Backend.DataTypes.Methods; +using InnovEnergy.App.Backend.Websockets; +using InnovEnergy.Lib.Utils; + +namespace InnovEnergy.App.Backend.Services; + +public static class AutoTicketService +{ + private const String NicoLappName = "Nico Lapp"; + private const String AtefSarrajName = "Atef Sarraj"; + + private const String TagAlarm = "auto:alarm"; + private const String TagOffline = "auto:offline"; + + private const Int32 AlarmStatus = (Int32)StatusType.Alarm; + private const Int32 OfflineStatus = (Int32)StatusType.Offline; + + public static async Task MaybeCreateForAlarmAsync( + Installation installation, + Int32 prevStatus, + IReadOnlyList alarms) + { + if (!IsSodistore(installation.Product)) return; + if (prevStatus == AlarmStatus) return; + + var assignee = FindAdminByName(NicoLappName); + if (assignee is null) + { + Console.WriteLine($"[AutoTicket] assignee \"{NicoLappName}\" not found, skipping alarm ticket for installation {installation.Id}"); + return; + } + + if (alarms.Count == 0) + { + Console.WriteLine($"[AutoTicket] alarm transition for installation {installation.Id} carried no alarm payload, skipping (likely transient)"); + return; + } + + if (HasOpenAutoTicket(installation.Id, TagAlarm)) return; + + var lang = String.IsNullOrWhiteSpace(assignee.Language) ? "en" : assignee.Language; + var firstName = SplitCamelCase(alarms[0].Description); + var suffix = alarms.Count > 1 ? $" (+{alarms.Count - 1} more)" : ""; + var subject = $"[Auto] {installation.Name} entered error state [{firstName}]{suffix}"; + + var description = await BuildAlarmDescriptionAsync(installation, prevStatus, alarms, lang); + + CreateTicket(installation, assignee, subject, description, TagAlarm); + } + + public static Task MaybeCreateForOfflineAsync( + Installation installation, + Int32 prevStatus, + DateTime lastSeen) + { + if (!IsSodistore(installation.Product)) return Task.CompletedTask; + if (prevStatus == OfflineStatus) return Task.CompletedTask; + + var assignee = FindAdminByName(AtefSarrajName); + if (assignee is null) + { + Console.WriteLine($"[AutoTicket] assignee \"{AtefSarrajName}\" not found, skipping offline ticket for installation {installation.Id}"); + return Task.CompletedTask; + } + + if (HasOpenAutoTicket(installation.Id, TagOffline)) return Task.CompletedTask; + + var subject = $"[Auto] {installation.Name} went offline"; + var description = BuildOfflineDescription(installation, prevStatus, lastSeen); + + CreateTicket(installation, assignee, subject, description, TagOffline); + return Task.CompletedTask; + } + + private static void CreateTicket( + Installation installation, + User assignee, + String subject, + String description, + String tag) + { + var ticket = new Ticket + { + Subject = subject, + Description = description, + Status = (Int32)TicketStatus.Open, + Priority = (Int32)TicketPriority.Critical, + Category = (Int32)TicketCategory.Other, + SubCategory = 0, + Source = (Int32)TicketSource.AutoAlert, + InstallationId = installation.Id, + AssigneeId = assignee.Id, + CreatedByUserId = assignee.Id, + Tags = tag, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + if (!Db.Create(ticket)) + { + Console.WriteLine($"[AutoTicket] Db.Create failed for installation {installation.Id} ({tag})"); + return; + } + + var timelineOk = Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.Created, + Description = $"Ticket auto-created by status monitor ({tag}).", + ActorType = (Int32)TimelineActorType.System, + ActorId = null, + CreatedAt = DateTime.UtcNow + }); + if (!timelineOk) + Console.WriteLine($"[AutoTicket] timeline event insert failed for ticket {ticket.Id} (informational only — ticket itself is fine)"); + + _ = Task.Run(async () => + { + try { await assignee.SendTicketAssignedEmail(ticket); } + catch (Exception ex) { Console.WriteLine($"[AutoTicket] assignment email failed for ticket {ticket.Id}: {ex.Message}"); } + }); + + var lang = String.IsNullOrWhiteSpace(assignee.Language) ? "en" : assignee.Language; + TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning(); + } + + private static Boolean HasOpenAutoTicket(Int64 installationId, String tag) + { + return Db.GetTicketsForInstallation(installationId).Any(t => + t.Source == (Int32)TicketSource.AutoAlert + && (t.Status == (Int32)TicketStatus.Open || t.Status == (Int32)TicketStatus.InProgress) + && t.Tags == tag); + } + + private static User? FindAdminByName(String name) => + Db.Users.FirstOrDefault(u => u.UserType == 2 && u.Name == name); + + private static Boolean IsSodistore(Int32 product) => product is 2 or 3 or 4 or 5; + + private static String SplitCamelCase(String name) => + Regex.Replace(name ?? "", @"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ").Trim(); + + private static async Task BuildAlarmDescriptionAsync( + Installation installation, + Int32 prevStatus, + IReadOnlyList alarms, + String lang) + { + var sb = new StringBuilder(); + sb.AppendLine("Auto-created by status monitor."); + sb.AppendLine($"Installation: {installation.Name} (id {installation.Id})"); + sb.AppendLine($"Location: {installation.Location}"); + sb.AppendLine($"Transition: {StatusLabel(prevStatus)} → Alarm"); + sb.AppendLine($"Detected at: {DateTime.UtcNow:O} UTC"); + sb.AppendLine(); + sb.AppendLine($"—— Alarms ({alarms.Count}) ——"); + + foreach (var alarm in alarms) + { + var displayName = SplitCamelCase(alarm.Description); + sb.AppendLine(); + sb.AppendLine($"[{displayName}] (raw: {alarm.Description}) — {alarm.Date} {alarm.Time} from {alarm.CreatedBy}"); + + DiagnosticResponse? diag = null; + try + { + diag = await DiagnosticService.DiagnoseAsync(installation.Id, alarm.Description, lang); + } + catch (Exception ex) + { + Console.WriteLine($"[AutoTicket] DiagnoseAsync failed for {alarm.Description}: {ex.Message}"); + } + + if (diag is null) + { + sb.AppendLine(" (no diagnosis available)"); + continue; + } + + sb.AppendLine($" Explanation: {diag.Explanation}"); + + if (diag.Causes is { Count: > 0 }) + { + sb.AppendLine(" Likely causes:"); + foreach (var cause in diag.Causes) sb.AppendLine($" - {cause}"); + } + + if (diag.NextSteps is { Count: > 0 }) + { + sb.AppendLine(" Suggested next steps:"); + var i = 1; + foreach (var step in diag.NextSteps) sb.AppendLine($" {i++}. {step}"); + } + } + + return sb.ToString(); + } + + private static String BuildOfflineDescription(Installation installation, Int32 prevStatus, DateTime lastSeen) + { + // lastSeen is captured from WebsocketManager which uses DateTime.Now (server local time); + // normalise to UTC so the rendered timestamps + "min ago" delta are consistent and unambiguous. + var lastSeenUtc = lastSeen.Kind == DateTimeKind.Utc ? lastSeen : lastSeen.ToUniversalTime(); + var minutesAgo = (DateTime.UtcNow - lastSeenUtc).TotalMinutes; + var (productLabel, timeoutMin) = OfflineProfile(installation.Product); + + var sb = new StringBuilder(); + sb.AppendLine("Auto-created by status monitor."); + sb.AppendLine($"Installation: {installation.Name} (id {installation.Id})"); + sb.AppendLine($"Location: {installation.Location}"); + sb.AppendLine($"Transition: {StatusLabel(prevStatus)} → Offline"); + sb.AppendLine($"Detected at: {DateTime.UtcNow:O} UTC"); + sb.AppendLine(); + sb.AppendLine($"Last heartbeat received: {lastSeenUtc:O} UTC ({minutesAgo:F1} min ago)"); + sb.AppendLine($"Expected heartbeat interval for {productLabel}: {timeoutMin} min"); + sb.AppendLine(); + sb.AppendLine("This ticket was auto-created because no message was received from the"); + sb.AppendLine("installation within the expected interval. Please verify network/VPN"); + sb.AppendLine("connectivity, then check the device side."); + + return sb.ToString(); + } + + private static String StatusLabel(Int32 status) => status switch + { + (Int32)StatusType.Offline => "Offline", + (Int32)StatusType.Green => "Green", + (Int32)StatusType.Warning => "Warning", + (Int32)StatusType.Alarm => "Alarm", + -2 => "Unknown", + _ => $"Status({status})" + }; + + private static (String label, Int32 timeoutMinutes) OfflineProfile(Int32 product) => product switch + { + (Int32)ProductType.SodioHome => ("Sodistore Home", 4), + (Int32)ProductType.SodiStoreMax => ("Sodistore Max", 2), + (Int32)ProductType.SodistoreGrid => ("Sodistore Grid", 2), + (Int32)ProductType.SodistorePro => ("Sodistore Pro", 4), + _ => ($"Product {product}", 0) + }; +} diff --git a/csharp/App/Backend/Websockets/RabbitMQManager.cs b/csharp/App/Backend/Websockets/RabbitMQManager.cs index 39f67a6e4..476594f73 100644 --- a/csharp/App/Backend/Websockets/RabbitMQManager.cs +++ b/csharp/App/Backend/Websockets/RabbitMQManager.cs @@ -3,6 +3,7 @@ using System.Text.Json; using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.DataTypes; using InnovEnergy.App.Backend.DataTypes.Methods; +using InnovEnergy.App.Backend.Services; using InnovEnergy.Lib.Utils; using RabbitMQ.Client; using RabbitMQ.Client.Events; @@ -194,10 +195,12 @@ public static class RabbitMqManager && receivedStatusMessage.Status == AlarmStatus) { var prev = prevStatus; + var alarmsSnapshot = (IReadOnlyList) + (receivedStatusMessage.Alarms?.ToList() ?? new List()); _ = Task.Run(async () => { - try { await installation.SendAlarmNotificationToSupport(prev); } - catch (Exception ex) { Console.WriteLine($"[AlarmNotify] failed for {installationId}: {ex.Message}"); } + try { await AutoTicketService.MaybeCreateForAlarmAsync(installation, prev, alarmsSnapshot); } + catch (Exception ex) { Console.WriteLine($"[AutoTicket] alarm failed for {installationId}: {ex.Message}"); } }); } diff --git a/csharp/App/Backend/Websockets/WebsockerManager.cs b/csharp/App/Backend/Websockets/WebsockerManager.cs index 73a7c6589..c2359892c 100644 --- a/csharp/App/Backend/Websockets/WebsockerManager.cs +++ b/csharp/App/Backend/Websockets/WebsockerManager.cs @@ -5,6 +5,7 @@ using System.Text; using System.Text.Json; using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.DataTypes; +using InnovEnergy.App.Backend.Services; using InnovEnergy.Lib.Utils; namespace InnovEnergy.App.Backend.Websockets; @@ -18,6 +19,7 @@ public static class WebsocketManager while (true) { var idsToInform = new List(); + var offlineTransitions = new List<(Int64 Id, Int32 PrevStatus, DateTime LastSeen)>(); lock (InstallationConnections) { @@ -38,12 +40,18 @@ public static class WebsocketManager Console.WriteLine("Installation ID is " + installationConnection.Key); Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp); + var prevStatus = installationConnection.Value.Status; + var lastSeen = installationConnection.Value.Timestamp; + installationConnection.Value.Status = (int)StatusType.Offline; Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline); if (installationConnection.Value.Connections.Count > 0) { idsToInform.Add(installationConnection.Key); } + + if (prevStatus != (int)StatusType.Offline) + offlineTransitions.Add((installationConnection.Key, prevStatus, lastSeen)); } } } @@ -52,6 +60,18 @@ public static class WebsocketManager foreach (var id in idsToInform) await InformWebsocketsForInstallation(id); + // Auto-create tickets for fresh offline transitions (outside the lock — needs DB reads + Task.Run) + foreach (var (id, prevStatus, lastSeen) in offlineTransitions) + { + var installation = Db.GetInstallationById(id); + if (installation is null) continue; + _ = Task.Run(async () => + { + try { await AutoTicketService.MaybeCreateForOfflineAsync(installation, prevStatus, lastSeen); } + catch (Exception ex) { Console.WriteLine($"[AutoTicket] offline failed for {id}: {ex.Message}"); } + }); + } + await Task.Delay(TimeSpan.FromMinutes(1)); } }