added ticket dashboard backend: data models, CRUD, AI diagnosis service, and 9 controller endpoints

Week 1 of ticket dashboard MVP — backend only:
- 4 new SQLite tables: Ticket, TicketComment, TicketAiDiagnosis, TicketTimelineEvent
- CRUD methods in Database/ partial classes with cascade delete
- TicketDiagnosticService for async Mistral AI diagnosis (fire-and-forget)
- 9 admin-only Controller endpoints for ticket CRUD, comments, and detail view
- Ticket cleanup integrated into Delete(Installation) cascade
This commit is contained in:
Yinyin Liu 2026-03-05 14:03:34 +01:00
parent 534b00aeb8
commit 9cee5398d4
12 changed files with 539 additions and 1 deletions

View File

@ -1746,6 +1746,172 @@ public class Controller : ControllerBase
return Ok(result); return Ok(result);
} }
// ═══════════════════════════════════════════════
// TICKET ENDPOINTS (admin-only)
// ═══════════════════════════════════════════════
[HttpGet(nameof(GetAllTickets))]
public ActionResult<IEnumerable<Ticket>> 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<IEnumerable<Ticket>> 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<Ticket> 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<Ticket> 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<Ticket> 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<IEnumerable<TicketComment>> 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<TicketComment> 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<Object> 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)
};
}
} }

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -75,6 +75,12 @@ public static partial class Db
public static Boolean Create(HourlyEnergyRecord record) => Insert(record); public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
public static Boolean Create(AiInsightCache cache) => Insert(cache); 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) public static void HandleAction(UserAction newAction)
{ {
//Find the total number of actions for this installation //Find the total number of actions for this installation

View File

@ -32,6 +32,12 @@ public static partial class Db
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>(); public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>(); public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
// Ticket system tables
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
public static TableQuery<TicketComment> TicketComments => Connection.Table<TicketComment>();
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
public static void Init() public static void Init()
{ {
@ -63,6 +69,12 @@ public static partial class Db
Connection.CreateTable<DailyEnergyRecord>(); Connection.CreateTable<DailyEnergyRecord>();
Connection.CreateTable<HourlyEnergyRecord>(); Connection.CreateTable<HourlyEnergyRecord>();
Connection.CreateTable<AiInsightCache>(); Connection.CreateTable<AiInsightCache>();
// Ticket system tables
Connection.CreateTable<Ticket>();
Connection.CreateTable<TicketComment>();
Connection.CreateTable<TicketAiDiagnosis>();
Connection.CreateTable<TicketTimelineEvent>();
}); });
// One-time migration: normalize legacy long-form language values to ISO codes // One-time migration: normalize legacy long-form language values to ISO codes
@ -112,6 +124,12 @@ public static partial class Db
fileConnection.CreateTable<HourlyEnergyRecord>(); fileConnection.CreateTable<HourlyEnergyRecord>();
fileConnection.CreateTable<AiInsightCache>(); fileConnection.CreateTable<AiInsightCache>();
// Ticket system tables
fileConnection.CreateTable<Ticket>();
fileConnection.CreateTable<TicketComment>();
fileConnection.CreateTable<TicketAiDiagnosis>();
fileConnection.CreateTable<TicketTimelineEvent>();
return fileConnection; return fileConnection;
//return CopyDbToMemory(fileConnection); //return CopyDbToMemory(fileConnection);
} }

View File

@ -124,6 +124,17 @@ public static partial class Db
Warnings .Delete(w => w.InstallationId == installation.Id); Warnings .Delete(w => w.InstallationId == installation.Id);
UserActions .Delete(a => a.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; return Installations.Delete(i => i.Id == installation.Id) > 0;
} }
} }
@ -199,6 +210,21 @@ public static partial class Db
if (count > 0) Backup(); 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;
}
}
/// <summary> /// <summary>
/// Deletes all report records older than 1 year. Called annually on Jan 2 /// 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 /// after yearly reports are created. Uses fetch-then-delete for string-compared

View File

@ -156,4 +156,33 @@ public static partial class Db
&& c.ReportId == reportId && c.ReportId == reportId
&& c.Language == language) && c.Language == language)
?.InsightText; ?.InsightText;
// ── Ticket Queries ──────────────────────────────────────────────────
public static Ticket? GetTicketById(Int64 id)
=> Tickets.FirstOrDefault(t => t.Id == id);
public static List<Ticket> GetAllTickets()
=> Tickets.OrderByDescending(t => t.UpdatedAt).ToList();
public static List<Ticket> GetTicketsForInstallation(Int64 installationId)
=> Tickets
.Where(t => t.InstallationId == installationId)
.OrderByDescending(t => t.CreatedAt)
.ToList();
public static List<TicketComment> 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<TicketTimelineEvent> GetTimelineForTicket(Int64 ticketId)
=> TicketTimelineEvents
.Where(e => e.TicketId == ticketId)
.OrderBy(e => e.CreatedAt)
.ToList();
} }

View File

@ -56,4 +56,7 @@ public static partial class Db
} }
} }
// Ticket system
public static Boolean Update(Ticket ticket) => Update(obj: ticket);
public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis);
} }

View File

@ -27,6 +27,7 @@ public static class Program
Db.Init(); Db.Init();
LoadEnvFile(); LoadEnvFile();
DiagnosticService.Initialize(); DiagnosticService.Initialize();
TicketDiagnosticService.Initialize();
AlarmReviewService.StartDailyScheduler(); AlarmReviewService.StartDailyScheduler();
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready // DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly // ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly

View File

@ -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;
/// <summary>
/// Generates AI-powered diagnoses for support tickets.
/// Runs async after ticket creation; stores result in TicketAiDiagnosis table.
/// </summary>
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);
/// <summary>
/// Called fire-and-forget after ticket creation.
/// Creates a TicketAiDiagnosis row (Pending → Analyzing → Completed | Failed).
/// </summary>
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<string> 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<TicketDiagnosisResult?> 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<dynamic>(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<TicketDiagnosisRaw>(json);
if (parsed is null) return null;
return new TicketDiagnosisResult
{
RootCause = parsed.RootCause,
Confidence = parsed.Confidence,
RecommendedActionsJson = JsonConvert.SerializeObject(parsed.RecommendedActions ?? Array.Empty<string>())
};
}
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; }
}