Implemented a basic ticket system

This commit is contained in:
Marcel Baumgartner
2023-08-04 14:00:25 +02:00
parent 606085c012
commit de45ff40d8
20 changed files with 3021 additions and 663 deletions

View File

@@ -59,6 +59,15 @@ public class ConfigV1
[JsonProperty("Sentry")] public SentryData Sentry { get; set; } = new();
[JsonProperty("Stripe")] public StripeData Stripe { get; set; } = new();
[JsonProperty("Tickets")] public TicketsData Tickets { get; set; } = new();
}
public class TicketsData
{
[JsonProperty("WelcomeMessage")]
[Description("The message that will be sent when a user created a ticket")]
public string WelcomeMessage { get; set; } = "Welcome to the support";
}
public class StripeData

View File

@@ -44,6 +44,9 @@ public class DataContext : DbContext
public DbSet<SecurityLog> SecurityLogs { get; set; }
public DbSet<BlocklistIp> BlocklistIps { get; set; }
public DbSet<WhitelistIp> WhitelistIps { get; set; }
public DbSet<Ticket> Tickets { get; set; }
public DbSet<TicketMessage> TicketMessages { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

View File

@@ -0,0 +1,19 @@
using Moonlight.App.Models.Misc;
namespace Moonlight.App.Database.Entities;
public class Ticket
{
public int Id { get; set; }
public string IssueTopic { get; set; } = "";
public string IssueDescription { get; set; } = "";
public string IssueTries { get; set; } = "";
public User CreatedBy { get; set; }
public User? AssignedTo { get; set; }
public TicketPriority Priority { get; set; }
public TicketStatus Status { get; set; }
public TicketSubject Subject { get; set; }
public int SubjectId { get; set; }
public List<TicketMessage> Messages { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,14 @@
namespace Moonlight.App.Database.Entities;
public class TicketMessage
{
public int Id { get; set; }
public string Content { get; set; } = "";
public string? AttachmentUrl { get; set; }
public User? Sender { get; set; }
public bool IsSystemMessage { get; set; }
public bool IsEdited { get; set; }
public bool IsSupportMessage { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.App.Database.Migrations
{
/// <inheritdoc />
public partial class AddNewTicketModels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tickets",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
IssueTopic = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IssueDescription = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IssueTries = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedById = table.Column<int>(type: "int", nullable: false),
AssignedToId = table.Column<int>(type: "int", nullable: true),
Priority = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Subject = table.Column<int>(type: "int", nullable: false),
SubjectId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tickets", x => x.Id);
table.ForeignKey(
name: "FK_Tickets_Users_AssignedToId",
column: x => x.AssignedToId,
principalTable: "Users",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Tickets_Users_CreatedById",
column: x => x.CreatedById,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "TicketMessages",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Content = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AttachmentUrl = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SenderId = table.Column<int>(type: "int", nullable: true),
IsSystemMessage = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsEdited = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsSupportMessage = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
TicketId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TicketMessages", x => x.Id);
table.ForeignKey(
name: "FK_TicketMessages_Tickets_TicketId",
column: x => x.TicketId,
principalTable: "Tickets",
principalColumn: "Id");
table.ForeignKey(
name: "FK_TicketMessages_Users_SenderId",
column: x => x.SenderId,
principalTable: "Users",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TicketMessages_SenderId",
table: "TicketMessages",
column: "SenderId");
migrationBuilder.CreateIndex(
name: "IX_TicketMessages_TicketId",
table: "TicketMessages",
column: "TicketId");
migrationBuilder.CreateIndex(
name: "IX_Tickets_AssignedToId",
table: "Tickets",
column: "AssignedToId");
migrationBuilder.CreateIndex(
name: "IX_Tickets_CreatedById",
table: "Tickets",
column: "CreatedById");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TicketMessages");
migrationBuilder.DropTable(
name: "Tickets");
}
}
}

View File

@@ -714,6 +714,97 @@ namespace Moonlight.App.Database.Migrations
b.ToTable("SupportChatMessages");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.Ticket", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int?>("AssignedToId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CreatedById")
.HasColumnType("int");
b.Property<string>("IssueDescription")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("IssueTopic")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("IssueTries")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("Subject")
.HasColumnType("int");
b.Property<int>("SubjectId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AssignedToId");
b.HasIndex("CreatedById");
b.ToTable("Tickets");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.TicketMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("AttachmentUrl")
.HasColumnType("longtext");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEdited")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSupportMessage")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSystemMessage")
.HasColumnType("tinyint(1)");
b.Property<int?>("SenderId")
.HasColumnType("int");
b.Property<int?>("TicketId")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("TicketId");
b.ToTable("TicketMessages");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
{
b.Property<int>("Id")
@@ -1039,6 +1130,36 @@ namespace Moonlight.App.Database.Migrations
b.Navigation("Sender");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.Ticket", b =>
{
b.HasOne("Moonlight.App.Database.Entities.User", "AssignedTo")
.WithMany()
.HasForeignKey("AssignedToId");
b.HasOne("Moonlight.App.Database.Entities.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AssignedTo");
b.Navigation("CreatedBy");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.TicketMessage", b =>
{
b.HasOne("Moonlight.App.Database.Entities.User", "Sender")
.WithMany()
.HasForeignKey("SenderId");
b.HasOne("Moonlight.App.Database.Entities.Ticket", null)
.WithMany("Messages")
.HasForeignKey("TicketId");
b.Navigation("Sender");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
{
b.HasOne("Moonlight.App.Database.Entities.Subscription", "CurrentSubscription")
@@ -1094,6 +1215,11 @@ namespace Moonlight.App.Database.Migrations
b.Navigation("Variables");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.Ticket", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.WebSpace", b =>
{
b.Navigation("Databases");

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.App.Models.Misc;
namespace Moonlight.App.Models.Forms;
public class CreateTicketDataModel
{
[Required(ErrorMessage = "You need to specify a issue topic")]
[MinLength(5, ErrorMessage = "The issue topic needs to be longer than 5 characters")]
public string IssueTopic { get; set; }
[Required(ErrorMessage = "You need to specify a issue description")]
[MinLength(10, ErrorMessage = "The issue description needs to be longer than 10 characters")]
public string IssueDescription { get; set; }
[Required(ErrorMessage = "You need to specify your tries to solve this issue")]
public string IssueTries { get; set; }
public TicketSubject Subject { get; set; }
public int SubjectId { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Moonlight.App.Models.Misc;
public enum TicketPriority
{
Low = 0,
Medium = 1,
High = 2,
Critical = 3
}

View File

@@ -0,0 +1,9 @@
namespace Moonlight.App.Models.Misc;
public enum TicketStatus
{
Closed = 0,
Open = 1,
WaitingForUser = 2,
Pending = 3
}

View File

@@ -0,0 +1,9 @@
namespace Moonlight.App.Models.Misc;
public enum TicketSubject
{
Webspace = 0,
Server = 1,
Domain = 2,
Other = 3
}

View File

@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Components.Forms;
using Moonlight.App.Database.Entities;
using Moonlight.App.Models.Misc;
using Moonlight.App.Services.Files;
using Moonlight.App.Services.Sessions;
namespace Moonlight.App.Services.Tickets;
public class TicketAdminService
{
private readonly TicketServerService TicketServerService;
private readonly IdentityService IdentityService;
private readonly BucketService BucketService;
public Ticket? Ticket { get; set; }
public TicketAdminService(
TicketServerService ticketServerService,
IdentityService identityService,
BucketService bucketService)
{
TicketServerService = ticketServerService;
IdentityService = identityService;
BucketService = bucketService;
}
public async Task<Dictionary<Ticket, TicketMessage?>> GetAssigned()
{
return await TicketServerService.GetUserAssignedTickets(IdentityService.User);
}
public async Task<Dictionary<Ticket, TicketMessage?>> GetUnAssigned()
{
return await TicketServerService.GetUnAssignedTickets();
}
public async Task<Ticket> Create(string issueTopic, string issueDescription, string issueTries,
TicketSubject subject, int subjectId)
{
return await TicketServerService.Create(
IdentityService.User,
issueTopic,
issueDescription,
issueTries,
subject,
subjectId
);
}
public async Task<TicketMessage> Send(string content, IBrowserFile? file = null)
{
string? attachment = null;
if (file != null)
{
attachment = await BucketService.StoreFile(
"tickets",
file.OpenReadStream(1024 * 1024 * 5),
file.Name);
}
return await TicketServerService.SendMessage(
Ticket!,
IdentityService.User,
content,
attachment,
true
);
}
public async Task UpdateStatus(TicketStatus status)
{
await TicketServerService.UpdateStatus(Ticket!, status);
}
public async Task UpdatePriority(TicketPriority priority)
{
await TicketServerService.UpdatePriority(Ticket!, priority);
}
public async Task<TicketMessage[]> GetMessages()
{
return await TicketServerService.GetMessages(Ticket!);
}
public async Task Claim()
{
await TicketServerService.Claim(Ticket!, IdentityService.User);
}
public async Task UnClaim()
{
await TicketServerService.Claim(Ticket!);
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Components.Forms;
using Moonlight.App.Database.Entities;
using Moonlight.App.Models.Misc;
using Moonlight.App.Services.Files;
using Moonlight.App.Services.Sessions;
namespace Moonlight.App.Services.Tickets;
public class TicketClientService
{
private readonly TicketServerService TicketServerService;
private readonly IdentityService IdentityService;
private readonly BucketService BucketService;
public Ticket? Ticket { get; set; }
public TicketClientService(
TicketServerService ticketServerService,
IdentityService identityService,
BucketService bucketService)
{
TicketServerService = ticketServerService;
IdentityService = identityService;
BucketService = bucketService;
}
public async Task<Dictionary<Ticket, TicketMessage?>> Get()
{
return await TicketServerService.GetUserTickets(IdentityService.User);
}
public async Task<Ticket> Create(string issueTopic, string issueDescription, string issueTries, TicketSubject subject, int subjectId)
{
return await TicketServerService.Create(
IdentityService.User,
issueTopic,
issueDescription,
issueTries,
subject,
subjectId
);
}
public async Task<TicketMessage> Send(string content, IBrowserFile? file = null)
{
string? attachment = null;
if (file != null)
{
attachment = await BucketService.StoreFile(
"tickets",
file.OpenReadStream(1024 * 1024 * 5),
file.Name);
}
return await TicketServerService.SendMessage(
Ticket!,
IdentityService.User,
content,
attachment
);
}
public async Task<TicketMessage[]> GetMessages()
{
return await TicketServerService.GetMessages(Ticket!);
}
}

View File

@@ -0,0 +1,249 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Models.Misc;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.Tickets;
public class TicketServerService
{
private readonly IServiceScopeFactory ServiceScopeFactory;
private readonly EventSystem Event;
private readonly ConfigService ConfigService;
public TicketServerService(
IServiceScopeFactory serviceScopeFactory,
EventSystem eventSystem,
ConfigService configService)
{
ServiceScopeFactory = serviceScopeFactory;
Event = eventSystem;
ConfigService = configService;
}
public async Task<Ticket> Create(User creator, string issueTopic, string issueDescription, string issueTries, TicketSubject subject, int subjectId)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var creatorUser = userRepo
.Get()
.First(x => x.Id == creator.Id);
var ticket = ticketRepo.Add(new()
{
Priority = TicketPriority.Low,
Status = TicketStatus.Open,
AssignedTo = null,
IssueTopic = issueTopic,
IssueDescription = issueDescription,
IssueTries = issueTries,
Subject = subject,
SubjectId = subjectId,
CreatedBy = creatorUser
});
await Event.Emit("tickets.new", ticket);
// Do automatic stuff here
await SendSystemMessage(ticket, ConfigService.Get().Moonlight.Tickets.WelcomeMessage);
//TODO: Check for opening times
return ticket;
}
public async Task SendSystemMessage(Ticket t, string content, string? attachmentUrl = null)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var ticket = ticketRepo.Get().First(x => x.Id == t.Id);
var message = new TicketMessage()
{
Content = content,
Sender = null,
AttachmentUrl = attachmentUrl,
IsSystemMessage = true
};
ticket.Messages.Add(message);
ticketRepo.Update(ticket);
await Event.Emit("tickets.message", message);
await Event.Emit($"tickets.{ticket.Id}.message", message);
}
public async Task UpdatePriority(Ticket t, TicketPriority priority)
{
if(t.Priority == priority)
return;
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var ticket = ticketRepo.Get().First(x => x.Id == t.Id);
ticket.Priority = priority;
ticketRepo.Update(ticket);
await Event.Emit("tickets.status", ticket);
await Event.Emit($"tickets.{ticket.Id}.status", ticket);
await SendSystemMessage(ticket, $"The ticket priority has been changed to: {priority}");
}
public async Task UpdateStatus(Ticket t, TicketStatus status)
{
if(t.Status == status)
return;
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var ticket = ticketRepo.Get().First(x => x.Id == t.Id);
ticket.Status = status;
ticketRepo.Update(ticket);
await Event.Emit("tickets.status", ticket);
await Event.Emit($"tickets.{ticket.Id}.status", ticket);
await SendSystemMessage(ticket, $"The ticket status has been changed to: {status}");
}
public async Task<TicketMessage> SendMessage(Ticket t, User sender, string content, string? attachmentUrl = null, bool isSupportMessage = false)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var ticket = ticketRepo.Get().First(x => x.Id == t.Id);
var user = userRepo.Get().First(x => x.Id == sender.Id);
var message = new TicketMessage()
{
Content = content,
Sender = user,
AttachmentUrl = attachmentUrl,
IsSupportMessage = isSupportMessage
};
ticket.Messages.Add(message);
ticketRepo.Update(ticket);
await Event.Emit("tickets.message", message);
await Event.Emit($"tickets.{ticket.Id}.message", message);
return message;
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUserTickets(User u)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Where(x => x.CreatedBy.Id == u.Id)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUserAssignedTickets(User u)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Where(x => x.Status != TicketStatus.Closed)
.Where(x => x.AssignedTo.Id == u.Id)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<Dictionary<Ticket, TicketMessage?>> GetUnAssignedTickets()
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.Include(x => x.AssignedTo)
.Where(x => x.AssignedTo == null)
.Where(x => x.Status != TicketStatus.Closed)
.ToArray();
var result = new Dictionary<Ticket, TicketMessage?>();
foreach (var ticket in tickets)
{
var message = ticket.Messages
.OrderByDescending(x => x.Id)
.FirstOrDefault();
result.Add(ticket, message);
}
return Task.FromResult(result);
}
public Task<TicketMessage[]> GetMessages(Ticket ticket)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var tickets = ticketRepo
.Get()
.Include(x => x.CreatedBy)
.Include(x => x.Messages)
.First(x => x.Id == ticket.Id);
return Task.FromResult(tickets.Messages.ToArray());
}
public async Task Claim(Ticket t, User? u = null)
{
using var scope = ServiceScopeFactory.CreateScope();
var ticketRepo = scope.ServiceProvider.GetRequiredService<Repository<Ticket>>();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var ticket = ticketRepo.Get().Include(x => x.AssignedTo).First(x => x.Id == t.Id);
var user = u == null ? u : userRepo.Get().First(x => x.Id == u.Id);
ticket.AssignedTo = user;
ticketRepo.Update(ticket);
await Event.Emit("tickets.status", ticket);
await Event.Emit($"tickets.{ticket.Id}.status", ticket);
}
}