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; // Edit/testing mode: alarms during commissioning shouldn't open tickets. if (installation.TestingMode) 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; // Edit/testing mode: offline transitions during commissioning shouldn't open tickets. if (installation.TestingMode) 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) }; }