Merge pull request #248 from Moonlight-Panel/AddTicketSystem

Added ticket system
This commit is contained in:
Marcel Baumgartner 2023-08-06 21:57:24 +02:00 committed by GitHub
commit f95312c1e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 3078 additions and 664 deletions

View file

@ -59,6 +59,15 @@ public class ConfigV1
[JsonProperty("Sentry")] public SentryData Sentry { get; set; } = new(); [JsonProperty("Sentry")] public SentryData Sentry { get; set; } = new();
[JsonProperty("Stripe")] public StripeData Stripe { 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 public class StripeData

View file

@ -44,6 +44,9 @@ public class DataContext : DbContext
public DbSet<SecurityLog> SecurityLogs { get; set; } public DbSet<SecurityLog> SecurityLogs { get; set; }
public DbSet<BlocklistIp> BlocklistIps { get; set; } public DbSet<BlocklistIp> BlocklistIps { get; set; }
public DbSet<WhitelistIp> WhitelistIps { 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) 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"); 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 => modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1039,6 +1130,36 @@ namespace Moonlight.App.Database.Migrations
b.Navigation("Sender"); 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 => modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
{ {
b.HasOne("Moonlight.App.Database.Entities.Subscription", "CurrentSubscription") b.HasOne("Moonlight.App.Database.Entities.Subscription", "CurrentSubscription")
@ -1094,6 +1215,11 @@ namespace Moonlight.App.Database.Migrations
b.Navigation("Variables"); b.Navigation("Variables");
}); });
modelBuilder.Entity("Moonlight.App.Database.Entities.Ticket", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.WebSpace", b => modelBuilder.Entity("Moonlight.App.Database.Entities.WebSpace", b =>
{ {
b.Navigation("Databases"); b.Navigation("Databases");

View file

@ -0,0 +1,56 @@
namespace Moonlight.App.Helpers;
public class ByteSizeValue
{
public long Bytes { get; set; }
public long KiloBytes
{
get => Bytes / 1024;
set => Bytes = value * 1024;
}
public long MegaBytes
{
get => KiloBytes / 1024;
set => KiloBytes = value * 1024;
}
public long GigaBytes
{
get => MegaBytes / 1024;
set => MegaBytes = value * 1024;
}
public static ByteSizeValue FromBytes(long bytes)
{
return new()
{
Bytes = bytes
};
}
public static ByteSizeValue FromKiloBytes(long kiloBytes)
{
return new()
{
KiloBytes = kiloBytes
};
}
public static ByteSizeValue FromMegaBytes(long megaBytes)
{
return new()
{
MegaBytes = megaBytes
};
}
public static ByteSizeValue FromGigaBytes(long gigaBytes)
{
return new()
{
GigaBytes = gigaBytes
};
}
}

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

View file

@ -29,6 +29,7 @@ using Moonlight.App.Services.Plugins;
using Moonlight.App.Services.Sessions; using Moonlight.App.Services.Sessions;
using Moonlight.App.Services.Statistics; using Moonlight.App.Services.Statistics;
using Moonlight.App.Services.SupportChat; using Moonlight.App.Services.SupportChat;
using Moonlight.App.Services.Tickets;
using Sentry; using Sentry;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
@ -109,10 +110,6 @@ namespace Moonlight
await databaseCheckupService.Perform(); await databaseCheckupService.Perform();
var backupHelper = new BackupHelper();
await backupHelper.CreateBackup(PathBuilder.File("storage", "backups",
$"{new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds()}.zip"));
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var pluginService = new PluginService(); var pluginService = new PluginService();
@ -217,6 +214,9 @@ namespace Moonlight
builder.Services.AddScoped<SubscriptionService>(); builder.Services.AddScoped<SubscriptionService>();
builder.Services.AddScoped<BillingService>(); builder.Services.AddScoped<BillingService>();
builder.Services.AddSingleton<PluginStoreService>(); builder.Services.AddSingleton<PluginStoreService>();
builder.Services.AddSingleton<TicketServerService>();
builder.Services.AddScoped<TicketClientService>();
builder.Services.AddScoped<TicketAdminService>();
builder.Services.AddScoped<SessionClientService>(); builder.Services.AddScoped<SessionClientService>();
builder.Services.AddSingleton<SessionServerService>(); builder.Services.AddSingleton<SessionServerService>();

View file

@ -0,0 +1,219 @@
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Services
@using Moonlight.App.Services.Files
@using System.Text.RegularExpressions
@inject ResourceService ResourceService
@inject SmartTranslateService SmartTranslateService
@foreach (var message in Messages.OrderByDescending(x => x.Id)) // Reverse messages to use auto scrolling
{
if (message.IsSupportMessage)
{
if (ViewAsSupport)
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br/>
}
}
@if (!string.IsNullOrEmpty(message.AttachmentUrl))
{
<div class="mt-3">
@if (Regex.IsMatch(message.AttachmentUrl, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))">
<i class="me-2 bx bx-download"></i> @(message.AttachmentUrl)
</a>
}
</div>
}
</div>
</div>
</div>
}
else
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
</div>
<div class="ms-3">
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br/>
}
}
@if (!string.IsNullOrEmpty(message.AttachmentUrl))
{
<div class="mt-3">
@if (Regex.IsMatch(message.AttachmentUrl, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))">
<i class="me-2 bx bx-download"></i> @(message.AttachmentUrl)
</a>
}
</div>
}
</div>
</div>
</div>
}
}
else if (message.IsSystemMessage)
{
<div class="separator separator-content border-primary my-15">
<span class="w-250px fw-bold">
@(message.Content)
</span>
</div>
}
else
{
if (ViewAsSupport)
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
</div>
<div class="ms-3">
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br/>
}
}
@if (!string.IsNullOrEmpty(message.AttachmentUrl))
{
<div class="mt-3">
@if (Regex.IsMatch(message.AttachmentUrl, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))">
<i class="me-2 bx bx-download"></i> @(message.AttachmentUrl)
</a>
}
</div>
}
</div>
</div>
</div>
}
else
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a href="#" class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">@(message.Sender!.FirstName) @(message.Sender!.LastName)</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender!))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br/>
}
}
@if (!string.IsNullOrEmpty(message.AttachmentUrl))
{
<div class="mt-3">
@if (Regex.IsMatch(message.AttachmentUrl, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("tickets", message.AttachmentUrl))">
<i class="me-2 bx bx-download"></i> @(message.AttachmentUrl)
</a>
}
</div>
}
</div>
</div>
</div>
}
}
}
@code
{
[Parameter]
public IEnumerable<TicketMessage> Messages { get; set; }
[Parameter]
public bool ViewAsSupport { get; set; }
}

View file

@ -88,7 +88,7 @@ else
else else
{ {
<span> <span>
@(Formatter.FormatSize(MemoryMetrics.Used)) <TL>of</TL> @(Formatter.FormatSize(MemoryMetrics.Total)) <TL>memory used</TL> @(Formatter.FormatSize(ByteSizeValue.FromKiloBytes(MemoryMetrics.Used).Bytes)) <TL>of</TL> @(Formatter.FormatSize(ByteSizeValue.FromKiloBytes(MemoryMetrics.Total).Bytes)) <TL>memory used</TL>
</span> </span>
} }
</span> </span>

View file

@ -1,101 +1,379 @@
@page "/admin/support" @page "/admin/support"
@page "/admin/support/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Database.Entities @using Moonlight.App.Database.Entities
@using Moonlight.App.Events @using Moonlight.App.Events
@using Moonlight.App.Services.SupportChat @using Moonlight.App.Helpers
@using Moonlight.App.Models.Misc
@using Moonlight.App.Services
@using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Tickets
@inject SupportChatServerService ServerService @inject TicketAdminService AdminService
@inject EventSystem Event @inject SmartTranslateService SmartTranslateService
@inject EventSystem EventSystem
@implements IDisposable @inject IdentityService IdentityService
@attribute [PermissionRequired(nameof(Permissions.AdminSupport))] @attribute [PermissionRequired(nameof(Permissions.AdminSupport))]
<LazyLoader @ref="LazyLoader" Load="Load"> @implements IDisposable
<div class="card">
<div class="card-body"> <div class="d-flex flex-column flex-lg-row">
<div class="d-flex flex-column flex-xl-row p-5 pb-0"> <div class="flex-column flex-lg-row-auto w-100 w-lg-300px w-xl-400px mb-10 mb-lg-0">
<div class="flex-lg-row-fluid me-xl-15 mb-20 mb-xl-0"> <div class="card card-flush">
<div class="mb-0"> <div class="card-body pt-5">
<h1 class="text-dark mb-6"> <div class="scroll-y me-n5 pe-5 h-200px h-lg-auto" data-kt-scroll="true" data-kt-scroll-activate="{default: false, lg: true}" data-kt-scroll-max-height="auto" data-kt-scroll-dependencies="#kt_header, #kt_app_header, #kt_toolbar, #kt_app_toolbar, #kt_footer, #kt_app_footer, #kt_chat_contacts_header" data-kt-scroll-wrappers="#kt_content, #kt_app_content, #kt_chat_contacts_body" data-kt-scroll-offset="5px" style="max-height: 601px;">
<TL>Open chats</TL> <div class="separator separator-content border-primary mb-10 mt-5">
</h1> <span class="w-250px fw-bold fs-5">
<div class="separator"></div> <TL>Unassigned tickets</TL>
<div class="mb-5"> </span>
@if (OpenChats.Any()) </div>
@foreach (var ticket in UnAssignedTickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/admin/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{ {
foreach (var chat in OpenChats) <div class="fw-semibold text-muted">
{ @(ticket.Value.Content)
<div class="d-flex mt-3 mb-3 ms-2 me-2">
<table>
<tr>
<td rowspan="2">
<span class="svg-icon svg-icon-2x me-5 ms-n1 svg-icon-success">
<i class="text-primary bx bx-md bx-message-dots"></i>
</span>
</td>
<td>
<a href="/admin/support/view/@(chat.Key.Id)" class="text-dark text-hover-primary fs-4 me-3 fw-semibold">
@(chat.Key.FirstName) @(chat.Key.LastName)
</a>
</td>
</tr>
<tr>
<td>
<span class="text-muted fw-semibold fs-6">
@if (chat.Value == null)
{
<TL>No message sent yet</TL>
}
else
{
@(chat.Value.Content)
}
</span>
</td>
</tr>
</table>
</div>
<div class="separator"></div>
}
}
else
{
<div class="alert alert-info">
<TL>No support chat is currently open</TL>
</div> </div>
} }
</div> </div>
</div> </div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div> </div>
</div>
if (ticket.Key != UnAssignedTickets.Last().Key)
{
<div class="separator"></div>
}
}
@if (AssignedTickets.Any())
{
<div class="separator separator-content border-primary mb-5 mt-8">
<span class="w-250px fw-bold fs-5">
<TL>Assigned tickets</TL>
</span>
</div>
}
@foreach (var ticket in AssignedTickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/admin/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div>
if (ticket.Key != AssignedTickets.Last().Key)
{
<div class="separator"></div>
}
}
</div> </div>
</div> </div>
</LazyLoader> </div>
</div>
<div class="flex-lg-row-fluid ms-lg-7 ms-xl-10">
<div class="card">
<div class="card-header">
@if (AdminService.Ticket != null)
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-3 fw-bold text-gray-900 me-1 mb-2 lh-1">@(AdminService.Ticket.IssueTopic)</span>
<div class="mb-0 lh-1">
<span class="fs-6 fw-bold text-muted me-2">
<TL>Status</TL>
</span>
@switch (AdminService.Ticket.Status)
{
case TicketStatus.Closed:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Open:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Pending:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.WaitingForUser:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted me-5">@(AdminService.Ticket.Status)</span>
<span class="fs-6 fw-bold text-muted me-2">
<TL>Priority</TL>
</span>
@switch (AdminService.Ticket.Priority)
{
case TicketPriority.Low:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Medium:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.High:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Critical:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted">@(AdminService.Ticket.Priority)</span>
</div>
</div>
</div>
<div class="card-toolbar">
<div class="input-group">
<div class="me-3">
@if (AdminService.Ticket!.AssignedTo == null)
{
<WButton Text="@(SmartTranslateService.Translate("Claim"))" OnClick="AdminService.Claim"/>
}
else
{
<WButton Text="@(SmartTranslateService.Translate("Unclaim"))" OnClick="AdminService.UnClaim"/>
}
</div>
<select @bind="Priority" class="form-select rounded-start">
@foreach (var priority in (TicketPriority[])Enum.GetValues(typeof(TicketPriority)))
{
if (Priority == priority)
{
<option value="@(priority)" selected="">@(priority)</option>
}
else
{
<option value="@(priority)">@(priority)</option>
}
}
</select>
<WButton Text="@(SmartTranslateService.Translate("Update priority"))"
CssClasses="btn-primary"
OnClick="UpdatePriority">
</WButton>
<select @bind="Status" class="form-select">
@foreach (var status in (TicketStatus[])Enum.GetValues(typeof(TicketStatus)))
{
if (Status == status)
{
<option value="@(status)" selected="">@(status)</option>
}
else
{
<option value="@(status)">@(status)</option>
}
}
</select>
<WButton Text="@(SmartTranslateService.Translate("Update status"))"
CssClasses="btn-primary"
OnClick="UpdateStatus">
</WButton>
</div>
</div>
}
else
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-4 fw-bold text-gray-900 me-1 mb-2 lh-1">
</span>
</div>
</div>
}
</div>
<div class="card-body">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@if (AdminService.Ticket == null)
{
}
else
{
<TicketMessageView Messages="Messages" ViewAsSupport="true"/>
}
</div>
</div>
@if (AdminService.Ticket != null)
{
<div class="card-footer pt-4" id="kt_chat_messenger_footer">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageText" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage">
</WButton>
</td>
</tr>
</table>
</div>
</div>
}
</div>
</div>
</div>
@code @code
{ {
private LazyLoader? LazyLoader; [Parameter]
private Dictionary<User, SupportChatMessage?> OpenChats = new(); public int Id { get; set; }
protected override async Task OnInitializedAsync() private Dictionary<Ticket, TicketMessage?> AssignedTickets;
private Dictionary<Ticket, TicketMessage?> UnAssignedTickets;
private List<TicketMessage> Messages = new();
private string MessageText;
private SmartFileSelect FileSelect;
private TicketPriority Priority;
private TicketStatus Status;
protected override async Task OnParametersSetAsync()
{ {
await Event.On<User>("supportChat.new", this, async user => await Unsubscribe();
{ await ReloadTickets();
//TODO: Play sound or smth. Add a config option await Subscribe();
OpenChats = await ServerService.GetOpenChats(); await InvokeAsync(StateHasChanged);
await InvokeAsync(StateHasChanged);
});
} }
private async Task Load(LazyLoader arg) // Only for initial load private async Task UpdatePriority()
{ {
OpenChats = await ServerService.GetOpenChats(); await AdminService.UpdatePriority(Priority);
}
private async Task UpdateStatus()
{
await AdminService.UpdateStatus(Status);
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageText) && FileSelect.SelectedFile != null)
MessageText = "File upload";
if (string.IsNullOrEmpty(MessageText))
return;
var msg = await AdminService.Send(MessageText, FileSelect.SelectedFile);
Messages.Add(msg);
MessageText = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
private async Task Subscribe()
{
await EventSystem.On<Ticket>("tickets.new", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
if (AdminService.Ticket != null)
{
await EventSystem.On<TicketMessage>($"tickets.{AdminService.Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await EventSystem.On<Ticket>($"tickets.{AdminService.Ticket.Id}.status", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
}
}
private async Task Unsubscribe()
{
await EventSystem.Off("tickets.new", this);
if (AdminService.Ticket != null)
{
await EventSystem.Off($"tickets.{AdminService.Ticket.Id}.message", this);
await EventSystem.Off($"tickets.{AdminService.Ticket.Id}.status", this);
}
}
private async Task ReloadTickets(bool reloadMessages = true)
{
AdminService.Ticket = null;
AssignedTickets = await AdminService.GetAssigned();
UnAssignedTickets = await AdminService.GetUnAssigned();
if (Id != 0)
{
AdminService.Ticket = AssignedTickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
if (AdminService.Ticket == null)
{
AdminService.Ticket = UnAssignedTickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
}
if (AdminService.Ticket == null)
return;
Status = AdminService.Ticket.Status;
Priority = AdminService.Ticket.Priority;
if (reloadMessages)
{
var msgs = await AdminService.GetMessages();
Messages = msgs.ToList();
}
}
} }
public async void Dispose() public async void Dispose()
{ {
await Event.Off("supportChat.new", this); await Unsubscribe();
} }
} }

View file

@ -1,313 +0,0 @@
@page "/admin/support/view/{Id:int}"
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Moonlight.App.Services.SupportChat
@using System.Text.RegularExpressions
@using Moonlight.App.Services.Files
@inject SupportChatAdminService AdminService
@inject UserRepository UserRepository
@inject SmartTranslateService SmartTranslateService
@inject ResourceService ResourceService
@implements IDisposable
@attribute [PermissionRequired(nameof(Permissions.AdminSupportView))]
<LazyLoader Load="Load">
@if (User == null)
{
<div class="alert alert-danger">
<TL>User not found</TL>
</div>
}
else
{
<div class="row">
<div class="d-flex flex-column flex-xl-row p-7">
<div class="flex-lg-row-fluid me-6 mb-20 mb-xl-0">
<div class="card">
<div class="card-body">
<LazyLoader Load="LoadMessages">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@foreach (var message in Messages)
{
if (message.Sender == null || message.Sender.Id != User.Id)
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">
@if (message.Sender != null)
{
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
}
else
{
<span>
<TL>System</TL>
</span>
}
</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@if (message.Sender == null)
{
<TL>@(message.Content)</TL>
}
else
{
foreach (var line in message.Content.Split("\n"))
{
@(line)<br/>
}
if (message.Attachment != "")
{
<div class="mt-3">
@if (Regex.IsMatch(message.Attachment, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("supportChat", message.Attachment))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("supportChat", message.Attachment))">
<i class="me-2 bx bx-download"></i> @(message.Attachment)
</a>
}
</div>
}
}
</div>
</div>
</div>
}
else
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender))">
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);}
@foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br />
}
}
@if (message.Attachment != "")
{
<div class="mt-3">
@if (Regex.IsMatch(message.Attachment, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("supportChat", message.Attachment))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("supportChat", message.Attachment))">
<i class="me-2 bx bx-download"></i> @(message.Attachment)
</a>
}
</div>
}
</div>
</div>
</div>
}
}
</div>
</LazyLoader>
</div>
<div class="card-footer">
@if (Typing.Any())
{
<span class="mb-5 fs-5 d-flex flex-row">
<div class="wave me-1">
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px"></div>
</div>
@if (Typing.Length > 1)
{
<span>
@(Typing.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL>
</span>
}
else
{
<span>
@(Typing.First()) <TL>is typing</TL>
</span>
}
</span>
}
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="SmartFileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="Content" @oninput="OnTyping" class="form-control mb-3 form-control-flush" rows="1" placeholder="Type a message">
</textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="Send">
</WButton>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="flex-column flex-lg-row-auto w-100 mw-lg-300px mw-xxl-350px">
<div class="card p-10 mb-15 pb-8">
<h2 class="text-dark fw-bold mb-2">
<TL>User information</TL>
</h2>
<div class="d-flex align-items-center mb-1">
<span class="fw-semibold text-gray-800 fs-5 m-0">
<TL>Name</TL>: @(User.FirstName) @User.LastName
</span>
</div>
<div class="d-flex align-items-center mb-2">
<span class="fw-semibold text-gray-800 fs-5 m-0">
<TL>Email</TL>: <a href="/admin/users/view/@User.Id">@(User.Email)</a>
</span>
</div>
<div class="align-items-center mt-3">
<span class="fw-semibold text-gray-800 fs-5 m-0">
<WButton Text="@(SmartTranslateService.Translate("Close ticket"))"
WorkingText="@(SmartTranslateService.Translate("Closing"))"
CssClasses="btn-danger float-end"
OnClick="CloseTicket">
</WButton>
</span>
</div>
</div>
</div>
</div>
</div>
}
</LazyLoader>
@code
{
[Parameter]
public int Id { get; set; }
private User? User;
private List<SupportChatMessage> Messages = new();
private string[] Typing = Array.Empty<string>();
private string Content = "";
private DateTime LastTypingTimestamp = DateTime.UtcNow.AddMinutes(-10);
private SmartFileSelect SmartFileSelect;
private async Task Load(LazyLoader arg)
{
User = UserRepository
.Get()
.FirstOrDefault(x => x.Id == Id);
if (User != null)
{
AdminService.OnMessage += OnMessage;
AdminService.OnTypingChanged += OnTypingChanged;
await AdminService.Start(User);
}
}
private async Task LoadMessages(LazyLoader arg)
{
Messages = (await AdminService.GetMessages()).ToList();
}
private async Task OnTypingChanged(string[] typing)
{
Typing = typing;
await InvokeAsync(StateHasChanged);
}
private async Task OnMessage(SupportChatMessage arg)
{
Messages.Insert(0, arg);
//TODO: Sound when message from system or admin
await InvokeAsync(StateHasChanged);
}
private async Task Send()
{
if (SmartFileSelect.SelectedFile != null && string.IsNullOrEmpty(Content))
Content = "File upload";
if (string.IsNullOrEmpty(Content))
return;
var message = await AdminService.SendMessage(Content, SmartFileSelect.SelectedFile);
Content = "";
await SmartFileSelect.RemoveSelection();
Messages.Insert(0, message);
await InvokeAsync(StateHasChanged);
}
private async Task CloseTicket()
{
await AdminService.Close();
}
private async Task OnTyping()
{
if ((DateTime.UtcNow - LastTypingTimestamp).TotalSeconds > 5)
{
LastTypingTimestamp = DateTime.UtcNow;
await AdminService.SendTyping();
}
}
public void Dispose()
{
AdminService?.Dispose();
}
}

View file

@ -1,275 +0,0 @@
@page "/support"
@using Moonlight.App.Services
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Services.SupportChat
@using System.Text.RegularExpressions
@using Moonlight.App.Services.Files
@using Moonlight.App.Services.Sessions
@inject ResourceService ResourceService
@inject SupportChatClientService ClientService
@inject SmartTranslateService SmartTranslateService
@inject IdentityService IdentityService
@implements IDisposable
<LazyLoader Load="Load">
<div class="row">
<div class="card">
<div class="card-body">
<LazyLoader Load="LoadMessages">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@foreach (var message in Messages.ToArray())
{
if (message.Sender == null || message.Sender.Id != IdentityService.User.Id)
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
@if (message.Sender != null)
{
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
}
else
{
<span>
<TL>System</TL>
</span>
}
</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@if (message.Sender == null)
{
<TL>@(message.Content)</TL>
}
else
{
int i = 0;
var arr = message.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var line in arr)
{
@line
if (i++ != arr.Length - 1)
{
<br />
}
}
if (message.Attachment != "")
{
<div class="mt-3">
@if (Regex.IsMatch(message.Attachment, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("supportChat", message.Attachment))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("supportChat", message.Attachment))">
<i class="me-2 bx bx-download"></i> @(message.Attachment)
</a>
}
</div>
}
}
</div>
</div>
</div>
}
else
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
<div class="d-flex align-items-center mb-2">
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<img alt="Avatar" src="@(ResourceService.Avatar(message.Sender))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@foreach (var line in message.Content.Split("\n"))
{
@(line)<br/>
}
@if (message.Attachment != "")
{
<div class="mt-3">
@if (Regex.IsMatch(message.Attachment, @"\.(jpg|jpeg|png|gif|bmp)$"))
{
<img src="@(ResourceService.BucketItem("supportChat", message.Attachment))" class="img-fluid" alt="Attachment"/>
}
else
{
<a class="btn btn-secondary" href="@(ResourceService.BucketItem("supportChat", message.Attachment))">
<i class="me-2 bx bx-download"></i> @(message.Attachment)
</a>
}
</div>
}
</div>
</div>
</div>
}
}
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
<div class="d-flex align-items-center mb-2">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
<span>
<TL>System</TL>
</span>
</a>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
<TL>Welcome to the support chat. Ask your question here and we will help you</TL>
</div>
</div>
</div>
</div>
</LazyLoader>
</div>
<div class="card-footer">
@if (Typing.Any())
{
<span class="mb-5 fs-5 d-flex flex-row">
<div class="wave me-1">
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px"></div>
</div>
@if (Typing.Length > 1)
{
<span>
@(Typing.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL>
</span>
}
else
{
<span>
@(Typing.First()) <TL>is typing</TL>
</span>
}
</span>
}
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="SmartFileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="Content" @oninput="OnTyping" class="form-control mb-3 form-control-flush" rows="1" placeholder="Type a message">
</textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="Send">
</WButton>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</LazyLoader>
@code
{
private List<SupportChatMessage> Messages = new();
private string[] Typing = Array.Empty<string>();
private string Content = "";
private DateTime LastTypingTimestamp = DateTime.UtcNow.AddMinutes(-10);
private SmartFileSelect SmartFileSelect;
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.SetText("Starting chat client");
ClientService.OnMessage += OnMessage;
ClientService.OnTypingChanged += OnTypingChanged;
await ClientService.Start();
}
private async Task LoadMessages(LazyLoader arg)
{
Messages = (await ClientService.GetMessages()).ToList();
}
private async Task OnTypingChanged(string[] typing)
{
Typing = typing;
await InvokeAsync(StateHasChanged);
}
private async Task OnMessage(SupportChatMessage message)
{
Messages.Insert(0, message);
//TODO: Sound when message from system or admin
await InvokeAsync(StateHasChanged);
}
private async Task Send()
{
if (SmartFileSelect.SelectedFile != null && string.IsNullOrEmpty(Content))
Content = "File upload";
var message = await ClientService.SendMessage(Content, SmartFileSelect.SelectedFile);
Content = "";
await SmartFileSelect.RemoveSelection();
Messages.Insert(0, message);
await InvokeAsync(StateHasChanged);
}
private async void OnTyping()
{
if ((DateTime.UtcNow - LastTypingTimestamp).TotalSeconds > 5)
{
LastTypingTimestamp = DateTime.UtcNow;
await ClientService.SendTyping();
}
}
public void Dispose()
{
ClientService?.Dispose();
}
private void OnFileChange(InputFileChangeEventArgs obj)
{
}
}

View file

@ -0,0 +1,468 @@
@page "/support"
@page "/support/{Id:int}"
@using Moonlight.App.Services.Tickets
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Models.Forms
@using Moonlight.App.Models.Misc
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Events
@using Moonlight.App.Services.Files
@using Moonlight.App.Services.Sessions
@using Moonlight.Shared.Components.Tickets
@inject TicketClientService ClientService
@inject Repository<Server> ServerRepository
@inject Repository<WebSpace> WebSpaceRepository
@inject Repository<Domain> DomainRepository
@inject SmartTranslateService SmartTranslateService
@inject IdentityService IdentityService
@inject NavigationManager NavigationManager
@inject ResourceService ResourceService
@inject EventSystem EventSystem
<div class="d-flex flex-column flex-lg-row">
<div class="flex-column flex-lg-row-auto w-100 w-lg-300px w-xl-400px mb-10 mb-lg-0">
<div class="card card-flush">
<div class="card-body pt-5">
<div class="scroll-y me-n5 pe-5 h-200px h-lg-auto">
<div class="d-flex flex-stack d-flex justify-content-center mb-5">
<a href="/support" class="btn btn-primary">
<TL>Create new ticket</TL>
</a>
</div>
<div class="separator"></div>
@foreach (var ticket in Tickets)
{
<div class="d-flex flex-stack py-4">
<div class="d-flex align-items-center">
<div class="ms-5">
<a href="/support/@(ticket.Key.Id)" class="fs-5 fw-bold text-gray-900 text-hover-primary mb-2">@(ticket.Key.IssueTopic)</a>
@if (ticket.Value != null)
{
<div class="fw-semibold text-muted">
@(ticket.Value.Content)
</div>
}
</div>
</div>
<div class="d-flex flex-column align-items-end ms-2">
@if (ticket.Value != null)
{
<span class="text-muted fs-7 mb-1">
@(Formatter.FormatAgoFromDateTime(ticket.Value.CreatedAt, SmartTranslateService))
</span>
}
</div>
</div>
if (ticket.Key != Tickets.Last().Key)
{
<div class="separator"></div>
}
}
</div>
</div>
</div>
</div>
<div class="flex-lg-row-fluid ms-lg-7 ms-xl-10">
<div class="card">
<div class="card-header">
@if (ClientService.Ticket != null)
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-3 fw-bold text-gray-900 me-1 mb-2 lh-1">@(ClientService.Ticket.IssueTopic)</span>
<div class="mb-0 lh-1">
<span class="fs-6 fw-bold text-muted me-2">
<TL>Status</TL>
</span>
@switch (ClientService.Ticket.Status)
{
case TicketStatus.Closed:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Open:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.Pending:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketStatus.WaitingForUser:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted me-5">@(ClientService.Ticket.Status)</span>
<span class="fs-6 fw-bold text-muted me-2">
<TL>Priority</TL>
</span>
@switch (ClientService.Ticket.Priority)
{
case TicketPriority.Low:
<span class="badge badge-success badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Medium:
<span class="badge badge-primary badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.High:
<span class="badge badge-warning badge-circle w-10px h-10px me-1"></span>
break;
case TicketPriority.Critical:
<span class="badge badge-danger badge-circle w-10px h-10px me-1"></span>
break;
}
<span class="fs-6 fw-semibold text-muted">@(ClientService.Ticket.Priority)</span>
</div>
</div>
</div>
<div class="card-toolbar">
<div class="me-n3">
<button class="btn btn-sm btn-icon btn-active-light-primary" data-kt-menu-trigger="click" data-kt-menu-placement="bottom-end">
<i class="ki-duotone ki-dots-square fs-2">
<span class="path1"></span><span class="path2"></span><span class="path3"></span><span class="path4"></span>
</i>
</button>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg-light-primary fw-semibold w-200px py-3" data-kt-menu="true">
<div class="menu-item px-3">
<div class="menu-content text-muted pb-2 px-3 fs-7 text-uppercase">
Contacts
</div>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="modal" data-bs-target="#kt_modal_users_search">
Add Contact
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link flex-stack px-3" data-bs-toggle="modal" data-bs-target="#kt_modal_invite_friends">
Invite Contacts
<span class="ms-2" data-bs-toggle="tooltip" aria-label="Specify a contact email to send an invitation" data-bs-original-title="Specify a contact email to send an invitation" data-kt-initialized="1">
<i class="ki-duotone ki-information fs-7">
<span class="path1"></span><span class="path2"></span><span class="path3"></span>
</i>
</span>
</a>
</div>
<div class="menu-item px-3" data-kt-menu-trigger="hover" data-kt-menu-placement="right-start">
<a href="#" class="menu-link px-3">
<span class="menu-title">Groups</span>
<span class="menu-arrow"></span>
</a>
<div class="menu-sub menu-sub-dropdown w-175px py-4">
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Create Group
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Invite Members
</a>
</div>
<div class="menu-item px-3">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Settings
</a>
</div>
</div>
</div>
<div class="menu-item px-3 my-1">
<a href="#" class="menu-link px-3" data-bs-toggle="tooltip" data-bs-original-title="Coming soon" data-kt-initialized="1">
Settings
</a>
</div>
</div>
</div>
</div>
}
else
{
<div class="card-title">
<div class="d-flex justify-content-center flex-column me-3">
<span class="fs-4 fw-bold text-gray-900 me-1 mb-2 lh-1">
<TL>Create a new ticket</TL>
</span>
</div>
</div>
}
</div>
<div class="card-body">
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@if (ClientService.Ticket == null)
{
<LazyLoader Load="LoadTicketCreate">
<SmartForm Model="Model" OnValidSubmit="OnValidSubmit">
<div class="mb-3">
<InputText @bind-Value="Model.IssueTopic"
placeholder="@(SmartTranslateService.Translate("Enter a title for your ticket"))"
class="form-control">
</InputText>
</div>
<div class="mb-3">
<InputTextArea @bind-Value="Model.IssueDescription"
placeholder="@(SmartTranslateService.Translate("Describe the issue you are experiencing"))"
class="form-control">
</InputTextArea>
</div>
<div class="mb-3">
<InputTextArea @bind-Value="Model.IssueTries"
placeholder="@(SmartTranslateService.Translate("Describe what you have tried to solve this issue"))"
class="form-control">
</InputTextArea>
</div>
<div class="mb-3">
<select @bind="Model.Subject" class="form-select">
@foreach (var subject in (TicketSubject[])Enum.GetValues(typeof(TicketSubject)))
{
if (Model.Subject == subject)
{
<option value="@(subject)" selected="">@(subject)</option>
}
else
{
<option value="@(subject)">@(subject)</option>
}
}
</select>
</div>
<div class="mb-3">
@if (Model.Subject == TicketSubject.Domain)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var domain in Domains)
{
if (Model.SubjectId == domain.Id)
{
<option value="@(domain.Id)" selected="">@(domain.Name).@(domain.SharedDomain.Name)</option>
}
else
{
<option value="@(domain.Id)">@(domain.Name).@(domain.SharedDomain.Name)</option>
}
}
</select>
}
else if (Model.Subject == TicketSubject.Server)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var server in Servers)
{
if (Model.SubjectId == server.Id)
{
<option value="@(server.Id)" selected="">@(server.Name)</option>
}
else
{
<option value="@(server.Id)">@(server.Name)</option>
}
}
</select>
}
else if (Model.Subject == TicketSubject.Webspace)
{
<select @bind="Model.SubjectId" class="form-select">
@foreach (var webSpace in WebSpaces)
{
if (Model.SubjectId == webSpace.Id)
{
<option value="@(webSpace.Id)" selected="">@(webSpace.Domain)</option>
}
else
{
<option value="@(webSpace.Id)">@(webSpace.Domain)</option>
}
}
</select>
}
</div>
<div class="text-end">
<button class="btn btn-primary" type="submit">
<TL>Create ticket</TL>
</button>
</div>
</SmartForm>
</LazyLoader>
}
else
{
<TicketMessageView Messages="Messages"/>
}
</div>
</div>
@if (ClientService.Ticket != null)
{
<div class="card-footer pt-4">
<div class="d-flex flex-stack">
<table class="w-100">
<tr>
<td class="align-top">
<SmartFileSelect @ref="FileSelect"></SmartFileSelect>
</td>
<td class="w-100">
<textarea @bind="MessageText" class="form-control mb-3 form-control-flush" rows="1" placeholder="@(SmartTranslateService.Translate("Type a message"))"></textarea>
</td>
<td class="align-top">
<WButton Text="@(SmartTranslateService.Translate("Send"))"
WorkingText="@(SmartTranslateService.Translate("Sending"))"
CssClasses="btn-primary ms-2"
OnClick="SendMessage">
</WButton>
</td>
</tr>
</table>
</div>
</div>
}
</div>
</div>
</div>
@code
{
[Parameter]
public int Id { get; set; }
private Dictionary<Ticket, TicketMessage?> Tickets;
private List<TicketMessage> Messages = new();
private CreateTicketDataModel Model = new();
private string MessageText;
private SmartFileSelect FileSelect;
private Server[] Servers;
private WebSpace[] WebSpaces;
private Domain[] Domains;
protected override async Task OnParametersSetAsync()
{
await Unsubscribe();
await ReloadTickets();
await Subscribe();
await InvokeAsync(StateHasChanged);
}
private Task LoadTicketCreate(LazyLoader _)
{
Servers = ServerRepository
.Get()
.Include(x => x.Owner)
.Where(x => x.Owner.Id == IdentityService.User.Id)
.ToArray();
WebSpaces = WebSpaceRepository
.Get()
.Include(x => x.Owner)
.Where(x => x.Owner.Id == IdentityService.User.Id)
.ToArray();
Domains = DomainRepository
.Get()
.Include(x => x.SharedDomain)
.Include(x => x.Owner)
.Where(x => x.Owner.Id == IdentityService.User.Id)
.ToArray();
return Task.CompletedTask;
}
private async Task OnValidSubmit()
{
var ticket = await ClientService.Create(
Model.IssueTopic,
Model.IssueDescription,
Model.IssueTries,
Model.Subject,
Model.SubjectId
);
Model = new();
NavigationManager.NavigateTo("/support/" + ticket.Id);
}
private async Task SendMessage()
{
if (string.IsNullOrEmpty(MessageText) && FileSelect.SelectedFile != null)
MessageText = "File upload";
if(string.IsNullOrEmpty(MessageText))
return;
var msg = await ClientService.Send(MessageText, FileSelect.SelectedFile);
Messages.Add(msg);
MessageText = "";
FileSelect.SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
private async Task Subscribe()
{
await EventSystem.On<Ticket>("tickets.new", this, async ticket =>
{
if (ticket.CreatedBy != null && ticket.CreatedBy.Id != IdentityService.User.Id)
return;
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
if (ClientService.Ticket != null)
{
await EventSystem.On<TicketMessage>($"tickets.{ClientService.Ticket.Id}.message", this, async message =>
{
if (message.Sender != null && message.Sender.Id == IdentityService.User.Id && !message.IsSupportMessage)
return;
Messages.Add(message);
await InvokeAsync(StateHasChanged);
});
await EventSystem.On<Ticket>($"tickets.{ClientService.Ticket.Id}.status", this, async _ =>
{
await ReloadTickets(false);
await InvokeAsync(StateHasChanged);
});
}
}
private async Task Unsubscribe()
{
await EventSystem.Off("tickets.new", this);
if (ClientService.Ticket != null)
{
await EventSystem.Off($"tickets.{ClientService.Ticket.Id}.message", this);
await EventSystem.Off($"tickets.{ClientService.Ticket.Id}.status", this);
}
}
private async Task ReloadTickets(bool reloadMessages = true)
{
ClientService.Ticket = null;
Tickets = await ClientService.Get();
if (Id != 0)
{
ClientService.Ticket = Tickets
.FirstOrDefault(x => x.Key.Id == Id)
.Key ?? null;
if (ClientService.Ticket == null)
return;
if (reloadMessages)
{
var msgs = await ClientService.GetMessages();
Messages = msgs.ToList();
}
}
}
}