feat: Export Everything Button

Signed-off-by: Ben Hall <ben@benh.codes>
This commit is contained in:
Ben Hall 2025-01-16 11:01:58 +00:00
parent 990c7feb09
commit a02a2d0798
4 changed files with 613 additions and 2 deletions

View File

@ -0,0 +1,481 @@
package api
import (
"context"
"fmt"
"strconv"
"time"
"github.com/TicketsBot/GoPanel/botcontext"
dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/types"
"github.com/TicketsBot/database"
v2 "github.com/TicketsBot/logarchiver/pkg/model/v2"
"github.com/TicketsBot/worker/bot/customisation"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
type (
AutoCloseData struct {
Enabled bool `json:"enabled"`
SinceOpenWithNoResponse int64 `json:"since_open_with_no_response"`
SinceLastMessage int64 `json:"since_last_message"`
OnUserLeave bool `json:"on_user_leave"`
}
Ticket struct {
TicketId int `json:"ticket_id"`
CloseReason *string `json:"close_reason"`
ClosedBy *uint64 `json:"closed_by"`
Rating *uint8 `json:"rating"`
Transcript v2.Transcript `json:"transcript"`
}
ColourMap map[customisation.Colour]utils.HexColour
Settings struct {
database.Settings
ClaimSettings database.ClaimSettings `json:"claim_settings"`
AutoCloseSettings AutoCloseData `json:"auto_close"`
TicketPermissions database.TicketPermissions `json:"ticket_permissions"`
Colours ColourMap `json:"colours"`
WelcomeMessage string `json:"welcome_message"`
TicketLimit uint8 `json:"ticket_limit"`
Category uint64 `json:"category,string"`
ArchiveChannel *uint64 `json:"archive_channel,string"`
NamingScheme database.NamingScheme `json:"naming_scheme"`
UsersCanClose bool `json:"users_can_close"`
CloseConfirmation bool `json:"close_confirmation"`
FeedbackEnabled bool `json:"feedback_enabled"`
Language *string `json:"language"`
}
Panel struct {
database.Panel
WelcomeMessage *types.CustomEmbed `json:"welcome_message"`
UseCustomEmoji bool `json:"use_custom_emoji"`
Emoji types.Emoji `json:"emote"`
Mentions []string `json:"mentions"`
Teams []int `json:"teams"`
UseServerDefaultNamingScheme bool `json:"use_server_default_naming_scheme"`
AccessControlList []database.PanelAccessControlRule `json:"access_control_list"`
}
MultiPanel struct {
database.MultiPanel
Panels []int `json:"panels"`
}
Export struct {
Settings Settings `json:"settings"`
Panels []Panel `json:"panels"`
MultiPanels []MultiPanel `json:"multi_panels"`
Tickets []Ticket `json:"tickets"`
}
)
var activeColours = []customisation.Colour{customisation.Green, customisation.Red}
func ExportHandler(ctx *gin.Context) {
guildId, selfId := ctx.Keys["guildid"].(uint64), ctx.Keys["userid"].(uint64)
botCtx, err := botcontext.ContextForGuild(guildId)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
guild, err := botCtx.GetGuild(context.Background(), guildId)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
if guild.OwnerId != selfId {
ctx.JSON(403, utils.ErrorStr("Only the server owner export server data"))
return
}
settings := getSettings(ctx, guildId)
multiPanels, err := dbclient.Client.MultiPanels.GetByGuild(ctx, guildId)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
multiPanelData := make([]MultiPanel, len(multiPanels))
group, _ := errgroup.WithContext(context.Background())
var dbTickets []database.Ticket
// ticket list
group.Go(func() (err error) {
dbTickets, err = dbclient.Client.Tickets.GetByOptions(ctx, database.TicketQueryOptions{
GuildId: guildId,
})
return
})
for i := range multiPanels {
i := i
multiPanel := multiPanels[i]
multiPanelData[i] = MultiPanel{
MultiPanel: multiPanel,
}
group.Go(func() error {
panels, err := dbclient.Client.MultiPanelTargets.GetPanels(ctx, multiPanel.Id)
if err != nil {
return err
}
panelIds := make([]int, len(panels))
for i, panel := range panels {
panelIds[i] = panel.PanelId
}
multiPanelData[i].Panels = panelIds
return nil
})
}
var dbPanels []database.PanelWithWelcomeMessage
var panelACLs map[int][]database.PanelAccessControlRule
var panelFields map[int][]database.EmbedField
// panels
group.Go(func() (err error) {
dbPanels, err = dbclient.Client.Panel.GetByGuildWithWelcomeMessage(ctx, guildId)
return
})
// access control lists
group.Go(func() (err error) {
panelACLs, err = dbclient.Client.PanelAccessControlRules.GetAllForGuild(ctx, guildId)
return
})
// all fields
group.Go(func() (err error) {
panelFields, err = dbclient.Client.EmbedFields.GetAllFieldsForPanels(ctx, guildId)
return
})
if err := group.Wait(); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
// ratings
ticketIds := make([]int, len(dbTickets))
for i, ticket := range dbTickets {
ticketIds[i] = ticket.Id
}
ticketGroup, _ := errgroup.WithContext(context.Background())
var (
ratings map[int]uint8
closeReasons map[int]database.CloseMetadata
tickets = make([]Ticket, len(dbTickets))
)
ticketGroup.Go(func() (err error) {
ratings, err = dbclient.Client.ServiceRatings.GetMulti(ctx, guildId, ticketIds)
return
})
ticketGroup.Go(func() (err error) {
closeReasons, err = dbclient.Client.CloseReason.GetMulti(ctx, guildId, ticketIds)
return
})
if err := ticketGroup.Wait(); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
transcriptGroup, _ := errgroup.WithContext(context.Background())
for i := range dbTickets {
ticket := dbTickets[i]
transcriptGroup.Go(func() (err error) {
var transcriptMessages v2.Transcript
transcriptMessages, err = utils.ArchiverClient.Get(ctx, guildId, ticket.Id)
if err != nil {
if err.Error() == "Transcript not found" {
err = nil
}
return
}
transcript := Ticket{
TicketId: ticket.Id,
Transcript: transcriptMessages,
}
if v, ok := ratings[ticket.Id]; ok {
transcript.Rating = &v
}
if v, ok := closeReasons[ticket.Id]; ok {
transcript.CloseReason = v.Reason
transcript.ClosedBy = v.ClosedBy
}
tickets[i] = transcript
return
})
}
if err := transcriptGroup.Wait(); err != nil {
fmt.Println(err)
ctx.JSON(500, utils.ErrorJson(err))
return
}
panels := make([]Panel, len(dbPanels))
panelGroup, _ := errgroup.WithContext(context.Background())
for i, p := range dbPanels {
i := i
p := p
panelGroup.Go(func() error {
var mentions []string
// get if we should mention the ticket opener
shouldMention, err := dbclient.Client.PanelUserMention.ShouldMentionUser(ctx, p.PanelId)
if err != nil {
return err
}
if shouldMention {
mentions = append(mentions, "user")
}
// get role mentions
roles, err := dbclient.Client.PanelRoleMentions.GetRoles(ctx, p.PanelId)
if err != nil {
return err
}
// convert to strings
for _, roleId := range roles {
mentions = append(mentions, strconv.FormatUint(roleId, 10))
}
teams, err := dbclient.Client.PanelTeams.GetTeamIds(ctx, p.PanelId)
if err != nil {
return err
}
if teams == nil {
teams = make([]int, 0)
}
var welcomeMessage *types.CustomEmbed
if p.WelcomeMessage != nil {
fields := panelFields[p.WelcomeMessage.Id]
welcomeMessage = types.NewCustomEmbed(p.WelcomeMessage, fields)
}
acl := panelACLs[p.PanelId]
if acl == nil {
acl = make([]database.PanelAccessControlRule, 0)
}
panels[i] = Panel{
Panel: p.Panel,
WelcomeMessage: welcomeMessage,
UseCustomEmoji: p.EmojiId != nil,
Emoji: types.NewEmoji(p.EmojiName, p.EmojiId),
Mentions: mentions,
Teams: teams,
UseServerDefaultNamingScheme: p.NamingScheme == nil,
AccessControlList: acl,
}
return nil
})
}
if err := panelGroup.Wait(); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
ctx.JSON(200, Export{
Settings: settings,
Panels: panels,
MultiPanels: multiPanelData,
Tickets: tickets,
})
}
func getSettings(ctx *gin.Context, guildId uint64) Settings {
var settings Settings
group, _ := errgroup.WithContext(context.Background())
// main settings
group.Go(func() (err error) {
settings.Settings, err = dbclient.Client.Settings.Get(ctx, guildId)
return
})
// claim settings
group.Go(func() (err error) {
settings.ClaimSettings, err = dbclient.Client.ClaimSettings.Get(ctx, guildId)
return
})
// auto close settings
group.Go(func() error {
tmp, err := dbclient.Client.AutoClose.Get(ctx, guildId)
if err != nil {
return err
}
settings.AutoCloseSettings = convertToAutoCloseData(tmp)
return nil
})
// ticket permissions
group.Go(func() (err error) {
settings.TicketPermissions, err = dbclient.Client.TicketPermissions.Get(ctx, guildId)
return
})
// colour map
group.Go(func() (err error) {
settings.Colours, err = getColourMap(guildId)
return
})
// welcome message
group.Go(func() (err error) {
settings.WelcomeMessage, err = dbclient.Client.WelcomeMessages.Get(ctx, guildId)
if err == nil && settings.WelcomeMessage == "" {
settings.WelcomeMessage = "Thank you for contacting support.\nPlease describe your issue and await a response."
}
return
})
// ticket limit
group.Go(func() (err error) {
settings.TicketLimit, err = dbclient.Client.TicketLimit.Get(ctx, guildId)
if err == nil && settings.TicketLimit == 0 {
settings.TicketLimit = 5 // Set default
}
return
})
// category
group.Go(func() (err error) {
settings.Category, err = dbclient.Client.ChannelCategory.Get(ctx, guildId)
return
})
// archive channel
group.Go(func() (err error) {
settings.ArchiveChannel, err = dbclient.Client.ArchiveChannel.Get(ctx, guildId)
return
})
// allow users to close
group.Go(func() (err error) {
settings.UsersCanClose, err = dbclient.Client.UsersCanClose.Get(ctx, guildId)
return
})
// naming scheme
group.Go(func() (err error) {
settings.NamingScheme, err = dbclient.Client.NamingScheme.Get(ctx, guildId)
return
})
// close confirmation
group.Go(func() (err error) {
settings.CloseConfirmation, err = dbclient.Client.CloseConfirmation.Get(ctx, guildId)
return
})
// close confirmation
group.Go(func() (err error) {
settings.FeedbackEnabled, err = dbclient.Client.FeedbackEnabled.Get(ctx, guildId)
return
})
// language
group.Go(func() error {
locale, err := dbclient.Client.ActiveLanguage.Get(ctx, guildId)
if err != nil {
return err
}
if locale != "" {
settings.Language = utils.Ptr(locale)
}
return nil
})
if err := group.Wait(); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
}
return settings
}
func getColourMap(guildId uint64) (ColourMap, error) {
raw, err := dbclient.Client.CustomColours.GetAll(context.Background(), guildId)
if err != nil {
return nil, err
}
colours := make(ColourMap)
for id, hex := range raw {
if !utils.Exists(activeColours, customisation.Colour(id)) {
continue
}
colours[customisation.Colour(id)] = utils.HexColour(hex)
}
for _, id := range activeColours {
if _, ok := colours[id]; !ok {
colours[id] = utils.HexColour(customisation.DefaultColours[id])
}
}
return colours, nil
}
func convertToAutoCloseData(settings database.AutoCloseSettings) (body AutoCloseData) {
body.Enabled = settings.Enabled
if settings.SinceOpenWithNoResponse != nil {
body.SinceOpenWithNoResponse = int64(*settings.SinceOpenWithNoResponse / time.Second)
}
if settings.SinceLastMessage != nil {
body.SinceLastMessage = int64(*settings.SinceLastMessage / time.Second)
}
if settings.OnUserLeave != nil {
body.OnUserLeave = *settings.OnUserLeave
}
return
}

View File

@ -1,9 +1,12 @@
package http package http
import ( import (
"time"
"github.com/TicketsBot/GoPanel/app/http/endpoints/api" "github.com/TicketsBot/GoPanel/app/http/endpoints/api"
"github.com/TicketsBot/GoPanel/app/http/endpoints/api/admin/botstaff" "github.com/TicketsBot/GoPanel/app/http/endpoints/api/admin/botstaff"
api_blacklist "github.com/TicketsBot/GoPanel/app/http/endpoints/api/blacklist" api_blacklist "github.com/TicketsBot/GoPanel/app/http/endpoints/api/blacklist"
api_export "github.com/TicketsBot/GoPanel/app/http/endpoints/api/export"
api_forms "github.com/TicketsBot/GoPanel/app/http/endpoints/api/forms" api_forms "github.com/TicketsBot/GoPanel/app/http/endpoints/api/forms"
api_integrations "github.com/TicketsBot/GoPanel/app/http/endpoints/api/integrations" api_integrations "github.com/TicketsBot/GoPanel/app/http/endpoints/api/integrations"
api_panels "github.com/TicketsBot/GoPanel/app/http/endpoints/api/panel" api_panels "github.com/TicketsBot/GoPanel/app/http/endpoints/api/panel"
@ -22,9 +25,7 @@ import (
"github.com/TicketsBot/GoPanel/config" "github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/common/permission" "github.com/TicketsBot/common/permission"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/penglongli/gin-metrics/ginmetrics"
"go.uber.org/zap" "go.uber.org/zap"
"time"
) )
func StartServer(logger *zap.Logger, sm *livechat.SocketManager) { func StartServer(logger *zap.Logger, sm *livechat.SocketManager) {
@ -119,6 +120,7 @@ func StartServer(logger *zap.Logger, sm *livechat.SocketManager) {
// Must be readable to load transcripts page // Must be readable to load transcripts page
guildAuthApiSupport.GET("/settings", api_settings.GetSettingsHandler) guildAuthApiSupport.GET("/settings", api_settings.GetSettingsHandler)
guildAuthApiAdmin.POST("/settings", api_settings.UpdateSettingsHandler) guildAuthApiAdmin.POST("/settings", api_settings.UpdateSettingsHandler)
guildAuthApiAdmin.GET("/export", api_export.ExportHandler)
guildAuthApiSupport.GET("/blacklist", api_blacklist.GetBlacklistHandler) guildAuthApiSupport.GET("/blacklist", api_blacklist.GetBlacklistHandler)
guildAuthApiSupport.POST("/blacklist", api_blacklist.AddBlacklistHandler) guildAuthApiSupport.POST("/blacklist", api_blacklist.AddBlacklistHandler)

View File

@ -0,0 +1,116 @@
<div class="modal" transition:fade>
<div class="modal-wrapper">
<Card footer="{true}" footerRight="{true}" fill="{false}">
<span slot="title">Export Settings</span>
<div slot="body" class="body-wrapper">
Are you sure you want to export all data for this server? This will include all settings, blacklist, tags, and transcripts.
<a id="export_data" style="display:none;"></a>
</div>
<div slot="footer" class="footer-wrapper">
<Button danger={true} on:click={dispatchClose}>Cancel</Button>
<div style="">
<Button on:click={dispatchConfirm}>Confirm</Button>
</div>
</div>
</Card>
</div>
</div>
<div class="modal-backdrop" transition:fade>
</div>
<svelte:window on:keydown={handleKeydown}/>
<script>
import {createEventDispatcher} from 'svelte';
import {fade} from 'svelte/transition'
import Card from "../Card.svelte";
import Button from "../Button.svelte";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import {notifyError, notifySuccess} from "../../js/util";
import axios from "axios";
import {API_URL} from "../../js/constants";
setDefaultHeaders();
export let guildId;
const dispatch = createEventDispatcher();
function dispatchClose() {
dispatch('close', {});
}
// Dispatch with data
async function dispatchConfirm() {
const res = await axios.get(`${API_URL}/api/${guildId}/export`);
if (res.status !== 200) {
notifyError(`Failed to export settings: ${res.data.error}`);
return;
}
let exportAnchor = document.getElementById('export_data');
exportAnchor.href = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(res.data))}`;
exportAnchor.setAttribute('download', 'export.json');
exportAnchor.click();
dispatchClose();
notifySuccess('Exported settings successfully');
}
function handleKeydown(e) {
if (e.key === "Escape") {
dispatchClose();
}
}
</script>
<style>
.modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 501;
display: flex;
justify-content: center;
align-items: center;
}
.modal-wrapper {
display: flex;
width: 40%;
}
@media only screen and (max-width: 1280px) {
.modal-wrapper {
width: 96%;
}
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 500;
background-color: #000;
opacity: .5;
}
.body-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.footer-wrapper {
display: flex;
flex-direction: row;
gap: 12px;
}
</style>

View File

@ -1,3 +1,6 @@
{#if modal}
<ExportModal {guildId} on:close={() => modal = false}/>
{/if}
<section class="sidebar"> <section class="sidebar">
<header> <header>
<img src="{iconUrl}" class="guild-icon" alt="Guild icon" width="50" height="50" on:error={handleIconLoadError} /> <img src="{iconUrl}" class="guild-icon" alt="Guild icon" width="50" height="50" on:error={handleIconLoadError} />
@ -24,7 +27,13 @@
<ManageSidebarLink {currentRoute} title="Tickets" icon="fa-ticket-alt" href="/manage/{guildId}/tickets" /> <ManageSidebarLink {currentRoute} title="Tickets" icon="fa-ticket-alt" href="/manage/{guildId}/tickets" />
<ManageSidebarLink {currentRoute} title="Blacklist" icon="fa-ban" href="/manage/{guildId}/blacklist" /> <ManageSidebarLink {currentRoute} title="Blacklist" icon="fa-ban" href="/manage/{guildId}/blacklist" />
<ManageSidebarLink {currentRoute} title="Tags" icon="fa-tags" href="/manage/{guildId}/tags" /> <ManageSidebarLink {currentRoute} title="Tags" icon="fa-tags" href="/manage/{guildId}/tags" />
{#if isAdmin}
<hr />
<Button on:click={() => modal = true} fill fullWidth=true>Export</Button>
{/if}
</ul> </ul>
</nav> </nav>
<nav class="bottom"> <nav class="bottom">
<hr/> <hr/>
@ -95,6 +104,9 @@
import ManageSidebarLink from "./ManageSidebarLink.svelte"; import ManageSidebarLink from "./ManageSidebarLink.svelte";
import SubNavigation from "./SubNavigation.svelte"; import SubNavigation from "./SubNavigation.svelte";
import SubNavigationLink from "./SubNavigationLink.svelte"; import SubNavigationLink from "./SubNavigationLink.svelte";
import Button from "../components/Button.svelte";
import ExportModal from "../components/manage/ExportModal.svelte";
let modal = false;
export let currentRoute; export let currentRoute;
export let permissionLevel; export let permissionLevel;