From 90ba4cfd210e7c3e68404ac3f0c14550744d0d90 Mon Sep 17 00:00:00 2001 From: rxdn <29165304+rxdn@users.noreply.github.com> Date: Sun, 8 Sep 2024 17:27:51 +0100 Subject: [PATCH] Overhaul ticket list page --- app/http/endpoints/api/ticket/gettickets.go | 73 +++--- app/http/endpoints/api/ticket/sendtag.go | 211 ++++++++++++++++ app/http/server.go | 1 + frontend/public/global.css | 1 + frontend/src/components/Button.svelte | 7 +- frontend/src/components/Card.svelte | 1 - frontend/src/components/ColumnSelector.svelte | 98 ++++++++ .../src/components/DiscordMessages.svelte | 156 ++++++++++-- frontend/src/components/form/Dropdown.svelte | 2 +- frontend/src/js/util.js | 22 ++ frontend/src/views/TicketView.svelte | 58 +++-- frontend/src/views/Tickets.svelte | 230 ++++++++++++++++-- go.mod | 2 +- go.sum | 2 + 14 files changed, 778 insertions(+), 86 deletions(-) create mode 100644 app/http/endpoints/api/ticket/sendtag.go create mode 100644 frontend/src/components/ColumnSelector.svelte diff --git a/app/http/endpoints/api/ticket/gettickets.go b/app/http/endpoints/api/ticket/gettickets.go index 9c031e8..d5a6ce8 100644 --- a/app/http/endpoints/api/ticket/gettickets.go +++ b/app/http/endpoints/api/ticket/gettickets.go @@ -1,24 +1,38 @@ package api import ( - "context" "github.com/TicketsBot/GoPanel/database" "github.com/TicketsBot/GoPanel/rpc/cache" "github.com/TicketsBot/GoPanel/utils" "github.com/gin-gonic/gin" "github.com/rxdn/gdl/objects/user" + "time" ) -type ticketResponse struct { - TicketId int `json:"id"` - PanelTitle string `json:"panel_title"` - User *user.User `json:"user,omitempty"` -} +type ( + listTicketsResponse struct { + Tickets []ticketData `json:"tickets"` + PanelTitles map[int]string `json:"panel_titles"` + ResolvedUsers map[uint64]user.User `json:"resolved_users"` + SelfId uint64 `json:"self_id,string"` + } + + ticketData struct { + TicketId int `json:"id"` + PanelId *int `json:"panel_id"` + UserId uint64 `json:"user_id,string"` + ClaimedBy *uint64 `json:"claimed_by,string"` + OpenedAt time.Time `json:"opened_at"` + LastResponseTime *time.Time `json:"last_response_time"` + LastResponseIsStaff *bool `json:"last_response_is_staff"` + } +) func GetTickets(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) guildId := ctx.Keys["guildid"].(uint64) - tickets, err := database.Client.Tickets.GetGuildOpenTickets(ctx, guildId) + tickets, err := database.Client.Tickets.GetGuildOpenTicketsWithMetadata(ctx, guildId) if err != nil { ctx.JSON(500, utils.ErrorJson(err)) return @@ -36,37 +50,38 @@ func GetTickets(ctx *gin.Context) { } // Get user objects - userIds := make([]uint64, len(tickets)) - for i, ticket := range tickets { - userIds[i] = ticket.UserId + userIds := make([]uint64, 0, int(float32(len(tickets))*1.5)) + for _, ticket := range tickets { + userIds = append(userIds, ticket.Ticket.UserId) + + if ticket.ClaimedBy != nil { + userIds = append(userIds, *ticket.ClaimedBy) + } } - users, err := cache.Instance.GetUsers(context.Background(), userIds) + users, err := cache.Instance.GetUsers(ctx, userIds) if err != nil { ctx.JSON(500, utils.ErrorJson(err)) return } - data := make([]ticketResponse, len(tickets)) + data := make([]ticketData, len(tickets)) for i, ticket := range tickets { - var user *user.User - if tmp, ok := users[ticket.UserId]; ok { - user = &tmp - } - - panelTitle := "Unknown" - if ticket.PanelId != nil { - if tmp, ok := panelTitles[*ticket.PanelId]; ok { - panelTitle = tmp - } - } - - data[i] = ticketResponse{ - TicketId: ticket.Id, - PanelTitle: panelTitle, - User: user, + data[i] = ticketData{ + TicketId: ticket.Id, + PanelId: ticket.PanelId, + UserId: ticket.Ticket.UserId, + ClaimedBy: ticket.ClaimedBy, + OpenedAt: ticket.OpenTime, + LastResponseTime: ticket.LastMessageTime, + LastResponseIsStaff: ticket.UserIsStaff, } } - ctx.JSON(200, data) + ctx.JSON(200, listTicketsResponse{ + Tickets: data, + PanelTitles: panelTitles, + ResolvedUsers: users, + SelfId: userId, + }) } diff --git a/app/http/endpoints/api/ticket/sendtag.go b/app/http/endpoints/api/ticket/sendtag.go new file mode 100644 index 0000000..d0bd661 --- /dev/null +++ b/app/http/endpoints/api/ticket/sendtag.go @@ -0,0 +1,211 @@ +package api + +import ( + "context" + "errors" + "fmt" + "github.com/TicketsBot/GoPanel/botcontext" + "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/rpc" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/GoPanel/utils/types" + "github.com/TicketsBot/common/premium" + "github.com/gin-gonic/gin" + "github.com/rxdn/gdl/objects/channel/embed" + "github.com/rxdn/gdl/rest" + "github.com/rxdn/gdl/rest/request" + "strconv" +) + +type sendTagBody struct { + TagId string `json:"tag_id"` +} + +func SendTag(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + userId := ctx.Keys["userid"].(uint64) + + botContext, err := botcontext.ContextForGuild(guildId) + if err != nil { + ctx.JSON(500, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + // Get ticket ID + ticketId, err := strconv.Atoi(ctx.Param("ticketId")) + if err != nil { + ctx.JSON(400, gin.H{ + "success": false, + "error": "Invalid ticket ID", + }) + return + } + + var body sendTagBody + if err := ctx.BindJSON(&body); err != nil { + ctx.JSON(400, gin.H{ + "success": false, + "error": "Tag is missing", + }) + return + } + + // Verify guild is premium + premiumTier, err := rpc.PremiumClient.GetTierByGuildId(ctx, guildId, true, botContext.Token, botContext.RateLimiter) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if premiumTier == premium.None { + ctx.JSON(402, gin.H{ + "success": false, + "error": "Guild is not premium", + }) + return + } + + // Get ticket + ticket, err := database.Client.Tickets.Get(ctx, ticketId, guildId) + + // Verify the ticket exists + if ticket.UserId == 0 { + ctx.JSON(404, gin.H{ + "success": false, + "error": "Ticket not found", + }) + return + } + + // Verify the user has permission to send to this guild + if ticket.GuildId != guildId { + ctx.JSON(403, gin.H{ + "success": false, + "error": "Guild ID doesn't match", + }) + return + } + + // Get tag + tag, ok, err := database.Client.Tag.Get(ctx, guildId, body.TagId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, gin.H{ + "success": false, + "error": "Tag not found", + }) + return + } + + // Preferably send via a webhook + webhook, err := database.Client.Webhooks.Get(ctx, guildId, ticketId) + if err != nil { + ctx.JSON(500, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + settings, err := database.Client.Settings.Get(ctx, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to fetch settings")) + return + } + + var embeds []*embed.Embed + if tag.Embed != nil { + embeds = []*embed.Embed{ + types.NewCustomEmbed(tag.Embed.CustomEmbed, tag.Embed.Fields).IntoDiscordEmbed(), + } + } + + if webhook.Id != 0 { + var webhookData rest.WebhookBody + if settings.AnonymiseDashboardResponses { + guild, err := botContext.GetGuild(context.Background(), guildId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to fetch guild")) + return + } + + webhookData = rest.WebhookBody{ + Content: utils.ValueOrZero(tag.Content), + Embeds: embeds, + Username: guild.Name, + AvatarUrl: guild.IconUrl(), + } + } else { + user, err := botContext.GetUser(context.Background(), userId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to fetch user")) + return + } + + webhookData = rest.WebhookBody{ + Content: utils.ValueOrZero(tag.Content), + Embeds: embeds, + Username: user.EffectiveName(), + AvatarUrl: user.AvatarUrl(256), + } + } + + // TODO: Ratelimit + _, err = rest.ExecuteWebhook(ctx, webhook.Token, nil, webhook.Id, true, webhookData) + + if err != nil { + // We can delete the webhook in this case + var unwrapped request.RestError + if errors.As(err, &unwrapped); unwrapped.StatusCode == 403 || unwrapped.StatusCode == 404 { + go database.Client.Webhooks.Delete(ctx, guildId, ticketId) + } + } else { + ctx.JSON(200, gin.H{ + "success": true, + }) + return + } + } + + message := utils.ValueOrZero(tag.Content) + if !settings.AnonymiseDashboardResponses { + user, err := botContext.GetUser(context.Background(), userId) + if err != nil { + ctx.JSON(500, utils.ErrorStr("Failed to fetch user")) + return + } + + message = fmt.Sprintf("**%s**: %s", user.EffectiveName(), message) + } + + if len(message) > 2000 { + message = message[0:1999] + } + + if ticket.ChannelId == nil { + ctx.JSON(404, gin.H{ + "success": false, + "error": "Ticket channel ID is nil", + }) + return + } + + if _, err = rest.CreateMessage(ctx, botContext.Token, botContext.RateLimiter, *ticket.ChannelId, rest.CreateMessageData{Content: message, Embeds: embeds}); err != nil { + ctx.JSON(500, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + ctx.JSON(200, gin.H{ + "success": true, + }) +} diff --git a/app/http/server.go b/app/http/server.go index 93c0c91..1fe009c 100644 --- a/app/http/server.go +++ b/app/http/server.go @@ -158,6 +158,7 @@ func StartServer(sm *livechat.SocketManager) { guildAuthApiSupport.GET("/tickets", api_ticket.GetTickets) guildAuthApiSupport.GET("/tickets/:ticketId", api_ticket.GetTicket) guildAuthApiSupport.POST("/tickets/:ticketId", rl(middleware.RateLimitTypeGuild, 5, time.Second*5), api_ticket.SendMessage) + guildAuthApiSupport.POST("/tickets/:ticketId/tag", rl(middleware.RateLimitTypeGuild, 5, time.Second*5), api_ticket.SendTag) guildAuthApiSupport.DELETE("/tickets/:ticketId", api_ticket.CloseTicket) // Websockets do not support headers: so we must implement authentication over the WS connection diff --git a/frontend/public/global.css b/frontend/public/global.css index 1d09665..a92f033 100644 --- a/frontend/public/global.css +++ b/frontend/public/global.css @@ -4,6 +4,7 @@ --primary: #995DF3; --primary-gradient: linear-gradient(71.3deg, #873ef5 0%, #995DF3 100%); --blue: #3472f7; + --background: #272727; } html, body { diff --git a/frontend/src/components/Button.svelte b/frontend/src/components/Button.svelte index dad9fc7..cf1f204 100644 --- a/frontend/src/components/Button.svelte +++ b/frontend/src/components/Button.svelte @@ -1,4 +1,4 @@ - + + + + + + + +{/if} +
-
- #ticket-{ticketId} -
-
- {#each messages as message} -
- {message.author.username}: {message.content} -
- {/each} -
-
-
- -
-
+
+ #ticket-{ticketId} +
+
+ {#each messages as message} +
+ {message.author.username}: {message.content} +
+ {/each} +
+
+
+ + {#if isPremium} + +
+ +
+ {/if} + +
diff --git a/frontend/src/components/form/Dropdown.svelte b/frontend/src/components/form/Dropdown.svelte index a99574a..58cb359 100644 --- a/frontend/src/components/form/Dropdown.svelte +++ b/frontend/src/components/form/Dropdown.svelte @@ -9,7 +9,7 @@ {/if} {/if} - diff --git a/frontend/src/js/util.js b/frontend/src/js/util.js index f4e0447..e6a3b4e 100644 --- a/frontend/src/js/util.js +++ b/frontend/src/js/util.js @@ -94,3 +94,25 @@ export function checkForParamAndRewrite(param) { return false; } + +const units = { + year : 24 * 60 * 60 * 1000 * 365, + month : 24 * 60 * 60 * 1000 * 365/12, + day : 24 * 60 * 60 * 1000, + hour : 60 * 60 * 1000, + minute: 60 * 1000, + second: 1000 +}; + +// From https://stackoverflow.com/a/53800501 +export function getRelativeTime(timestamp) { + const elapsed = timestamp - new Date(); + + // "Math.abs" accounts for both "past" & "future" scenarios + for (const u in units) { + if (Math.abs(elapsed) > units[u] || u === 'second') { + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + return rtf.format(Math.round(elapsed / units[u]), u) + } + } +} diff --git a/frontend/src/views/TicketView.svelte b/frontend/src/views/TicketView.svelte index 561f562..61c9e2e 100644 --- a/frontend/src/views/TicketView.svelte +++ b/frontend/src/views/TicketView.svelte @@ -1,19 +1,19 @@
- Support Teams + Ticket #{ticketId}

Close Ticket

-
+
-
-
- +
+
+
@@ -21,7 +21,7 @@

View Ticket

- +
@@ -46,6 +46,7 @@ let closeReason = ''; let messages = []; let isPremium = false; + let tags = []; let container; let WS_URL = env.WS_URL || 'ws://localhost:3000'; @@ -69,16 +70,31 @@ } async function sendMessage(e) { - let data = { - message: e.detail, - }; + if (e.detail.type === 'message') { + let data = { + message: e.detail, + }; - const res = await axios.post(`${API_URL}/api/${guildId}/tickets/${ticketId}`, data); - if (res.status !== 200) { - if (res.status === 429) { - notifyRatelimit(); - } else { - notifyError(res.data.error); + const res = await axios.post(`${API_URL}/api/${guildId}/tickets/${ticketId}`, data); + if (res.status !== 200) { + if (res.status === 429) { + notifyRatelimit(); + } else { + notifyError(res.data.error); + } + } + } else if (e.detail.type === 'tag') { + let data = { + tag_id: e.detail.tag_id, + }; + + const res = await axios.post(`${API_URL}/api/${guildId}/tickets/${ticketId}/tag`, data); + if (res.status !== 200) { + if (res.status === 429) { + notifyRatelimit(); + } else { + notifyError(res.data.error); + } } } } @@ -124,6 +140,16 @@ isPremium = res.data.premium; } + async function loadTags() { + const res = await axios.get(`${API_URL}/api/${guildId}/tags`); + if (res.status !== 200) { + notifyError(res.data.error); + return; + } + + tags = res.data; + } + withLoadingScreen(async () => { setDefaultHeaders(); await Promise.all([ @@ -135,6 +161,7 @@ if (isPremium) { connectWebsocket(); + await loadTags(); } }); @@ -152,7 +179,6 @@ justify-content: space-between; width: 96%; height: 100%; - margin-top: 30px; } .body-wrapper { diff --git a/frontend/src/views/Tickets.svelte b/frontend/src/views/Tickets.svelte index b7edb31..6622904 100644 --- a/frontend/src/views/Tickets.svelte +++ b/frontend/src/views/Tickets.svelte @@ -1,27 +1,94 @@ -
+
+ + + + Filters + +
+
+ + +
+ + + + + + + +
+
+ Open Tickets
- - - - + + + + + + + + - {#each tickets as ticket} + {#each filtered as ticket} + {@const user = data.resolved_users[ticket.user_id]} + {@const claimer = ticket.claimed_by ? data.resolved_users[ticket.claimed_by] : null} + {@const panel_title = data.panel_titles[ticket.panel_id?.toString()]} + - - - {#if ticket.user !== undefined} - - {:else} - - {/if} - + + + + + + + + + + + + +
IDPanelUserViewIDPanelUserOpenedClaimed ByLast MessageAwaiting ResponseView
{ticket.id}{ticket.panel_title}{ticket.user.username}Unknown + {ticket.id} + {panel_title || 'Unknown Panel'} + + {#if user} + {user.global_name || user.username} + {:else} + Unknown + {/if} + + {getRelativeTime(new Date(ticket.opened_at))} + + {#if ticket.claimed_by === null} + Unclaimed + {:else if claimer} + {claimer.global_name || claimer.username} + {:else} + Unknown + {/if} + + {#if ticket.last_response_time} + {getRelativeTime(new Date(ticket.last_response_time))} + {:else} + Never + {/if} + + {#if ticket.last_response_is_staff} + No + {:else} + Yes + {/if} + @@ -32,21 +99,85 @@
-
+ diff --git a/go.mod b/go.mod index 619974d..6587644 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/BurntSushi/toml v1.2.1 github.com/TicketsBot/archiverclient v0.0.0-20240613013458-accc062facc2 github.com/TicketsBot/common v0.0.0-20240829163809-6f60869d8941 - github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a + github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978 github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c github.com/TicketsBot/worker v0.0.0-20240829163848-84556c59ee72 github.com/apex/log v1.1.2 diff --git a/go.sum b/go.sum index 38ed2d2..fcc9bff 100644 --- a/go.sum +++ b/go.sum @@ -72,6 +72,8 @@ github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506 h1:mU2wx9pyb72 github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM= github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a h1:kNDfpVimz3kEBYpiIql1rJDDUHiBKZEdw+JLyH4Ne9w= github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM= +github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978 h1:31mE0z9PKGc2X8mImRq79x3/kosHoriWG5MLdf/lkJU= +github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM= github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c h1:OqGjFH6mbE6gd+NqI2ARJdtH3UUvhiAkD0r0fhGJK2s= github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c/go.mod h1:jgi2OXQKsd5nUnTIRkwvPmeuD/i7OhN68LKMssuQY1c= github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM=