diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 94e9d230d..38cf0160c 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -1746,6 +1746,172 @@ public class Controller : ControllerBase return Ok(result); } + // ═══════════════════════════════════════════════ + // TICKET ENDPOINTS (admin-only) + // ═══════════════════════════════════════════════ + + [HttpGet(nameof(GetAllTickets))] + public ActionResult> GetAllTickets(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Db.GetAllTickets(); + } + + [HttpGet(nameof(GetTicketsForInstallation))] + public ActionResult> GetTicketsForInstallation(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null) return NotFound(); + + return Db.GetTicketsForInstallation(installationId); + } + + [HttpGet(nameof(GetTicketById))] + public ActionResult GetTicketById(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var ticket = Db.GetTicketById(id); + return ticket is null ? NotFound() : ticket; + } + + [HttpPost(nameof(CreateTicket))] + public ActionResult CreateTicket([FromBody] Ticket ticket, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + ticket.CreatedByUserId = user.Id; + ticket.CreatedAt = DateTime.UtcNow; + ticket.UpdatedAt = DateTime.UtcNow; + ticket.Status = (Int32)TicketStatus.Open; + + if (!Db.Create(ticket)) return StatusCode(500, "Failed to create ticket."); + + Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.Created, + Description = $"Ticket created by {user.Name}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + + // Fire-and-forget AI diagnosis + TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id).SupressAwaitWarning(); + + return ticket; + } + + [HttpPut(nameof(UpdateTicket))] + public ActionResult UpdateTicket([FromBody] Ticket ticket, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var existing = Db.GetTicketById(ticket.Id); + if (existing is null) return NotFound(); + + ticket.CreatedAt = existing.CreatedAt; + ticket.CreatedByUserId = existing.CreatedByUserId; + ticket.UpdatedAt = DateTime.UtcNow; + + if (ticket.Status != existing.Status) + { + if (ticket.Status == (Int32)TicketStatus.Resolved) + ticket.ResolvedAt = DateTime.UtcNow; + + Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.StatusChanged, + Description = $"Status changed to {(TicketStatus)ticket.Status}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + } + + return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed."); + } + + [HttpDelete(nameof(DeleteTicket))] + public ActionResult DeleteTicket(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var ticket = Db.GetTicketById(id); + if (ticket is null) return NotFound(); + + return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed."); + } + + [HttpGet(nameof(GetTicketComments))] + public ActionResult> GetTicketComments(Int64 ticketId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Db.GetCommentsForTicket(ticketId); + } + + [HttpPost(nameof(AddTicketComment))] + public ActionResult AddTicketComment([FromBody] TicketComment comment, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var ticket = Db.GetTicketById(comment.TicketId); + if (ticket is null) return NotFound(); + + comment.AuthorType = (Int32)CommentAuthorType.Human; + comment.AuthorId = user.Id; + comment.CreatedAt = DateTime.UtcNow; + + if (!Db.Create(comment)) return StatusCode(500, "Failed to add comment."); + + Db.Create(new TicketTimelineEvent + { + TicketId = comment.TicketId, + EventType = (Int32)TimelineEventType.CommentAdded, + Description = $"Comment added by {user.Name}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + + ticket.UpdatedAt = DateTime.UtcNow; + Db.Update(ticket); + + return comment; + } + + [HttpGet(nameof(GetTicketDetail))] + public ActionResult GetTicketDetail(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var ticket = Db.GetTicketById(id); + if (ticket is null) return NotFound(); + + return new + { + ticket, + comments = Db.GetCommentsForTicket(id), + diagnosis = Db.GetDiagnosisForTicket(id), + timeline = Db.GetTimelineForTicket(id) + }; + } + } diff --git a/csharp/App/Backend/DataTypes/Ticket.cs b/csharp/App/Backend/DataTypes/Ticket.cs new file mode 100644 index 000000000..53659e3f0 --- /dev/null +++ b/csharp/App/Backend/DataTypes/Ticket.cs @@ -0,0 +1,30 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 } +public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 } +public enum TicketCategory { Hardware = 0, Software = 1, DataApi = 2, UserAccess = 3 } +public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 } + +public class Ticket +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + public String Subject { get; set; } = ""; + public String Description { get; set; } = ""; + + [Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open; + public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium; + public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware; + public Int32 Source { get; set; } = (Int32)TicketSource.Manual; + + [Indexed] public Int64 InstallationId { get; set; } + public Int64? AssigneeId { get; set; } + [Indexed] public Int64 CreatedByUserId { get; set; } + + public String Tags { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ResolvedAt { get; set; } +} diff --git a/csharp/App/Backend/DataTypes/TicketAiDiagnosis.cs b/csharp/App/Backend/DataTypes/TicketAiDiagnosis.cs new file mode 100644 index 000000000..fd9b54f7c --- /dev/null +++ b/csharp/App/Backend/DataTypes/TicketAiDiagnosis.cs @@ -0,0 +1,22 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum DiagnosisStatus { Pending = 0, Analyzing = 1, Completed = 2, Failed = 3 } +public enum DiagnosisFeedback { Accepted = 0, Rejected = 1, Overridden = 2 } + +public class TicketAiDiagnosis +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 TicketId { get; set; } + public Int32 Status { get; set; } = (Int32)DiagnosisStatus.Pending; + public String? RootCause { get; set; } + public Double? Confidence { get; set; } + public String? RecommendedActions { get; set; } // JSON array string + public String? SimilarTicketIds { get; set; } // comma-separated + public Int32? Feedback { get; set; } // null = no feedback yet + public String? OverrideText { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } +} diff --git a/csharp/App/Backend/DataTypes/TicketComment.cs b/csharp/App/Backend/DataTypes/TicketComment.cs new file mode 100644 index 000000000..a026712b3 --- /dev/null +++ b/csharp/App/Backend/DataTypes/TicketComment.cs @@ -0,0 +1,16 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum CommentAuthorType { Human = 0, AiAgent = 1 } + +public class TicketComment +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 TicketId { get; set; } + public Int32 AuthorType { get; set; } = (Int32)CommentAuthorType.Human; + public Int64? AuthorId { get; set; } + public String Body { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs b/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs new file mode 100644 index 000000000..05cc6ea6e --- /dev/null +++ b/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs @@ -0,0 +1,23 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum TimelineEventType +{ + Created = 0, StatusChanged = 1, Assigned = 2, + CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5 +} + +public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 } + +public class TicketTimelineEvent +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 TicketId { get; set; } + public Int32 EventType { get; set; } + public String Description { get; set; } = ""; + public Int32 ActorType { get; set; } = (Int32)TimelineActorType.System; + public Int64? ActorId { 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 98fee3b32..b70a427ac 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -74,6 +74,12 @@ public static partial class Db public static Boolean Create(DailyEnergyRecord record) => Insert(record); public static Boolean Create(HourlyEnergyRecord record) => Insert(record); public static Boolean Create(AiInsightCache cache) => Insert(cache); + + // Ticket system + public static Boolean Create(Ticket ticket) => Insert(ticket); + 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 void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index f062ea95f..73a22eb0a 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -32,6 +32,12 @@ public static partial class Db public static TableQuery HourlyRecords => Connection.Table(); public static TableQuery AiInsightCaches => Connection.Table(); + // Ticket system tables + public static TableQuery Tickets => Connection.Table(); + public static TableQuery TicketComments => Connection.Table(); + public static TableQuery TicketAiDiagnoses => Connection.Table(); + public static TableQuery TicketTimelineEvents => Connection.Table(); + public static void Init() { @@ -63,6 +69,12 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + + // Ticket system tables + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -112,6 +124,12 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); + // Ticket system tables + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + return fileConnection; //return CopyDbToMemory(fileConnection); } diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index c4fbe17c8..0eed3ef03 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -124,6 +124,17 @@ public static partial class Db Warnings .Delete(w => w.InstallationId == installation.Id); UserActions .Delete(a => a.InstallationId == installation.Id); + // Clean up tickets and their children for this installation + var ticketIds = Tickets.Where(t => t.InstallationId == installation.Id) + .Select(t => t.Id).ToList(); + foreach (var tid in ticketIds) + { + TicketComments .Delete(c => c.TicketId == tid); + TicketAiDiagnoses .Delete(d => d.TicketId == tid); + TicketTimelineEvents.Delete(e => e.TicketId == tid); + } + Tickets.Delete(t => t.InstallationId == installation.Id); + return Installations.Delete(i => i.Id == installation.Id) > 0; } } @@ -199,6 +210,21 @@ public static partial class Db if (count > 0) Backup(); } + public static Boolean Delete(Ticket ticket) + { + var deleteSuccess = RunTransaction(DeleteTicketAndChildren); + if (deleteSuccess) Backup(); + return deleteSuccess; + + Boolean DeleteTicketAndChildren() + { + TicketComments .Delete(c => c.TicketId == ticket.Id); + TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id); + TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id); + return Tickets.Delete(t => t.Id == ticket.Id) > 0; + } + } + /// /// Deletes all report records older than 1 year. Called annually on Jan 2 /// after yearly reports are created. Uses fetch-then-delete for string-compared diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index 0dccf3f78..d67ab0554 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -156,4 +156,33 @@ public static partial class Db && c.ReportId == reportId && c.Language == language) ?.InsightText; + + // ── Ticket Queries ────────────────────────────────────────────────── + + public static Ticket? GetTicketById(Int64 id) + => Tickets.FirstOrDefault(t => t.Id == id); + + public static List GetAllTickets() + => Tickets.OrderByDescending(t => t.UpdatedAt).ToList(); + + public static List GetTicketsForInstallation(Int64 installationId) + => Tickets + .Where(t => t.InstallationId == installationId) + .OrderByDescending(t => t.CreatedAt) + .ToList(); + + public static List GetCommentsForTicket(Int64 ticketId) + => TicketComments + .Where(c => c.TicketId == ticketId) + .OrderBy(c => c.CreatedAt) + .ToList(); + + public static TicketAiDiagnosis? GetDiagnosisForTicket(Int64 ticketId) + => TicketAiDiagnoses.FirstOrDefault(d => d.TicketId == ticketId); + + public static List GetTimelineForTicket(Int64 ticketId) + => TicketTimelineEvents + .Where(e => e.TicketId == ticketId) + .OrderBy(e => e.CreatedAt) + .ToList(); } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Update.cs b/csharp/App/Backend/Database/Update.cs index 50037b701..5ee1d011a 100644 --- a/csharp/App/Backend/Database/Update.cs +++ b/csharp/App/Backend/Database/Update.cs @@ -49,11 +49,14 @@ public static partial class Db public static void UpdateAction(UserAction updatedAction) { var existingAction = UserActions.FirstOrDefault(action => action.Id == updatedAction.Id); - + if (existingAction != null) { Update(updatedAction); } } + // Ticket system + public static Boolean Update(Ticket ticket) => Update(obj: ticket); + public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis); } \ No newline at end of file diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 7b1435534..54173a974 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -27,6 +27,7 @@ public static class Program Db.Init(); LoadEnvFile(); DiagnosticService.Initialize(); + TicketDiagnosticService.Initialize(); AlarmReviewService.StartDailyScheduler(); // DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready // ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly diff --git a/csharp/App/Backend/Services/TicketDiagnosticService.cs b/csharp/App/Backend/Services/TicketDiagnosticService.cs new file mode 100644 index 000000000..75191d89e --- /dev/null +++ b/csharp/App/Backend/Services/TicketDiagnosticService.cs @@ -0,0 +1,198 @@ +using Flurl.Http; +using InnovEnergy.App.Backend.Database; +using InnovEnergy.App.Backend.DataTypes; +using Newtonsoft.Json; + +namespace InnovEnergy.App.Backend.Services; + +/// +/// Generates AI-powered diagnoses for support tickets. +/// Runs async after ticket creation; stores result in TicketAiDiagnosis table. +/// +public static class TicketDiagnosticService +{ + private static string _apiKey = ""; + private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions"; + + public static void Initialize() + { + var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + Console.Error.WriteLine("[TicketDiagnosticService] MISTRAL_API_KEY not set – ticket AI disabled."); + else + _apiKey = apiKey; + + Console.WriteLine("[TicketDiagnosticService] initialised."); + } + + public static bool IsEnabled => !string.IsNullOrEmpty(_apiKey); + + /// + /// Called fire-and-forget after ticket creation. + /// Creates a TicketAiDiagnosis row (Pending → Analyzing → Completed | Failed). + /// + public static async Task DiagnoseTicketAsync(Int64 ticketId) + { + var ticket = Db.GetTicketById(ticketId); + if (ticket is null) return; + + var installation = Db.GetInstallationById(ticket.InstallationId); + if (installation is null) return; + + var diagnosis = new TicketAiDiagnosis + { + TicketId = ticketId, + Status = (Int32)DiagnosisStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + Db.Create(diagnosis); + + if (!IsEnabled) + { + diagnosis.Status = (Int32)DiagnosisStatus.Failed; + Db.Update(diagnosis); + return; + } + + diagnosis.Status = (Int32)DiagnosisStatus.Analyzing; + Db.Update(diagnosis); + + try + { + var productName = ((ProductType)installation.Product).ToString(); + + var recentErrors = Db.Errors + .Where(e => e.InstallationId == ticket.InstallationId) + .OrderByDescending(e => e.Date) + .ToList() + .Select(e => e.Description) + .Distinct() + .Take(5) + .ToList(); + + var prompt = BuildPrompt(ticket, productName, recentErrors); + var result = await CallMistralAsync(prompt); + + if (result is null) + { + diagnosis.Status = (Int32)DiagnosisStatus.Failed; + } + else + { + diagnosis.Status = (Int32)DiagnosisStatus.Completed; + diagnosis.RootCause = result.RootCause; + diagnosis.Confidence = result.Confidence; + diagnosis.RecommendedActions = result.RecommendedActionsJson; + diagnosis.CompletedAt = DateTime.UtcNow; + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"[TicketDiagnosticService] {ex.Message}"); + diagnosis.Status = (Int32)DiagnosisStatus.Failed; + } + + Db.Update(diagnosis); + + Db.Create(new TicketTimelineEvent + { + TicketId = ticketId, + EventType = (Int32)TimelineEventType.AiDiagnosisAttached, + Description = diagnosis.Status == (Int32)DiagnosisStatus.Completed + ? "AI diagnosis completed." + : "AI diagnosis failed.", + ActorType = (Int32)TimelineActorType.AiAgent, + CreatedAt = DateTime.UtcNow + }); + } + + private static string BuildPrompt(Ticket ticket, string productName, List recentErrors) + { + var recentList = recentErrors.Count > 0 + ? string.Join(", ", recentErrors) + : "none"; + + return $@"You are a senior field technician for {productName} battery energy storage systems. +A support ticket has been submitted with the following details: +Subject: {ticket.Subject} +Description: {ticket.Description} +Category: {(TicketCategory)ticket.Category} +Priority: {(TicketPriority)ticket.Priority} +Recent system alarms: {recentList} + +Analyze this ticket and respond in JSON only — no markdown, no explanation outside JSON: +{{ + ""rootCause"": ""One concise sentence describing the most likely root cause."", + ""confidence"": 0.85, + ""recommendedActions"": [""Action 1"", ""Action 2"", ""Action 3""] +}} +Confidence must be a number between 0.0 and 1.0."; + } + + private static async Task CallMistralAsync(string prompt) + { + try + { + var body = new + { + model = "mistral-small-latest", + messages = new[] { new { role = "user", content = prompt } }, + max_tokens = 300, + temperature = 0.2 + }; + + var text = await MistralUrl + .WithHeader("Authorization", $"Bearer {_apiKey}") + .PostJsonAsync(body) + .ReceiveString(); + + var envelope = JsonConvert.DeserializeObject(text); + var content = (string?)envelope?.choices?[0]?.message?.content; + + if (string.IsNullOrWhiteSpace(content)) return null; + + var json = content.Trim(); + if (json.StartsWith("```")) + { + var nl = json.IndexOf('\n'); + if (nl >= 0) json = json[(nl + 1)..]; + if (json.EndsWith("```")) json = json[..^3]; + json = json.Trim(); + } + + var parsed = JsonConvert.DeserializeObject(json); + if (parsed is null) return null; + + return new TicketDiagnosisResult + { + RootCause = parsed.RootCause, + Confidence = parsed.Confidence, + RecommendedActionsJson = JsonConvert.SerializeObject(parsed.RecommendedActions ?? Array.Empty()) + }; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[TicketDiagnosticService] HTTP error: {ex.Message}"); + return null; + } + } +} + +internal class TicketDiagnosisRaw +{ + [JsonProperty("rootCause")] + public String? RootCause { get; set; } + + [JsonProperty("confidence")] + public Double? Confidence { get; set; } + + [JsonProperty("recommendedActions")] + public String[]? RecommendedActions { get; set; } +} + +internal class TicketDiagnosisResult +{ + public String? RootCause { get; set; } + public Double? Confidence { get; set; } + public String? RecommendedActionsJson { get; set; } +}