From efd76032cdeb1081239ae2c8d41c2225b2997573 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:35:40 +0200 Subject: [PATCH 1/3] Add configurable panel close reasons Adds panel-level predefined close reasons with custom-reason controls, validation, persistence, and management UI. Enforces configured reasons across ticket closing, close requests, bulk actions, and transcript editing while exposing them through panel and ticket APIs. --- .../http/endpoints/api/panel/panelcreate.go | 10 ++ .../app/http/endpoints/api/panel/panelget.go | 8 ++ .../app/http/endpoints/api/panel/panellist.go | 43 ++++--- .../http/endpoints/api/panel/panelupdate.go | 35 +++++- .../http/endpoints/api/panel/validation.go | 48 ++++++++ .../endpoints/api/ticket/bulkcloserequest.go | 46 +++++--- .../endpoints/api/ticket/bulkclosetickets.go | 25 ++-- .../http/endpoints/api/ticket/closerequest.go | 10 ++ .../http/endpoints/api/ticket/closeticket.go | 8 +- .../http/endpoints/api/ticket/getticket.go | 6 + .../endpoints/api/ticket/panelclosereasons.go | 54 +++++++++ .../endpoints/api/ticket/updateclosereason.go | 7 +- .../http/endpoints/api/transcripts/list.go | 2 + backend/go.mod | 2 +- frontend/src/components/CloseReasonSelect.tsx | 85 ++++++++++++++ .../src/components/CloseReasonsEditor.tsx | 107 ++++++++++++++++++ frontend/src/constants/closeReasonLimits.ts | 4 + frontend/src/lib/close-reasons.ts | 35 ++++++ frontend/src/lib/panel-payload.ts | 7 ++ frontend/src/pages/manage/panels/create.tsx | 46 +++++++- frontend/src/pages/manage/panels/edit.tsx | 45 +++++++- frontend/src/pages/manage/tickets/_index.tsx | 98 +++++++++++++--- frontend/src/pages/manage/tickets/view.tsx | 54 ++++++--- .../src/pages/manage/transcripts/_index.tsx | 40 +++++-- frontend/src/types.d.ts | 8 ++ 25 files changed, 753 insertions(+), 80 deletions(-) create mode 100644 backend/app/http/endpoints/api/ticket/panelclosereasons.go create mode 100644 frontend/src/components/CloseReasonSelect.tsx create mode 100644 frontend/src/components/CloseReasonsEditor.tsx create mode 100644 frontend/src/constants/closeReasonLimits.ts create mode 100644 frontend/src/lib/close-reasons.ts diff --git a/backend/app/http/endpoints/api/panel/panelcreate.go b/backend/app/http/endpoints/api/panel/panelcreate.go index abbd40d6..c0461596 100644 --- a/backend/app/http/endpoints/api/panel/panelcreate.go +++ b/backend/app/http/endpoints/api/panel/panelcreate.go @@ -78,6 +78,7 @@ type panelBody struct { SupportCanView bool `json:"support_can_view"` SupportCanType bool `json:"support_can_type"` AutoClose PanelAutoCloseBody `json:"auto_close"` + CloseReasons *database.PanelCloseReasons `json:"close_reasons"` } func (p *panelBody) IntoPanelMessageData(customId string, showBranding bool) panelMessageData { @@ -145,6 +146,7 @@ func CreatePanel(c *gin.Context) { // Apply defaults ApplyPanelDefaults(&data) + normaliseCloseReasons(&data) // Not in DefaultApplicators: that also runs on update, where an empty list means "delete // every rule" rather than "unconfigured". @@ -332,6 +334,7 @@ func CreatePanel(c *gin.Context) { TeamIds: data.Teams, // Already validated AccessControlRules: *data.AccessControlList, // Already validated KBCategoryIds: data.KBCategoryIds, // Already validated + CloseReasons: data.CloseReasons, } // insert role mention data @@ -399,6 +402,7 @@ type panelCreateOptions struct { TeamIds []int AccessControlRules []database.PanelAccessControlRule KBCategoryIds []int + CloseReasons *database.PanelCloseReasons } func storePanel(ctx context.Context, panel database.Panel, options panelCreateOptions) (int, error) { @@ -435,6 +439,12 @@ func storePanel(ctx context.Context, panel database.Panel, options panelCreateOp return err } + if options.CloseReasons != nil { + if err := dbclient.Client.PanelCloseReasons.SetWithTx(ctx, tx, panelId, *options.CloseReasons); err != nil { + return err + } + } + return nil }) diff --git a/backend/app/http/endpoints/api/panel/panelget.go b/backend/app/http/endpoints/api/panel/panelget.go index 51b30b3e..5270f278 100644 --- a/backend/app/http/endpoints/api/panel/panelget.go +++ b/backend/app/http/endpoints/api/panel/panelget.go @@ -28,6 +28,7 @@ func GetPanel(c *gin.Context) { TicketPermissions database.TicketPermissions `json:"ticket_permissions"` AutoClose PanelAutoCloseResponse `json:"auto_close"` MentionBehaviour string `json:"mention_behaviour"` + CloseReasons database.PanelCloseReasons `json:"close_reasons"` } guildId := c.Keys["guildid"].(uint64) @@ -164,6 +165,12 @@ func GetPanel(c *gin.Context) { return } + closeReasons, err := dbclient.Client.PanelCloseReasons.Get(c, panel.PanelId) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to load panel")) + return + } + mentionBehaviour := panel.MentionBehaviour if mentionBehaviour == "" { mentionBehaviour = "none" @@ -184,6 +191,7 @@ func GetPanel(c *gin.Context) { TicketPermissions: ticketPerms, AutoClose: panelAutoCloseToResponse(autoCloseSettings), MentionBehaviour: mentionBehaviour, + CloseReasons: closeReasons, }) } diff --git a/backend/app/http/endpoints/api/panel/panellist.go b/backend/app/http/endpoints/api/panel/panellist.go index 20d43996..4913ceb4 100644 --- a/backend/app/http/endpoints/api/panel/panellist.go +++ b/backend/app/http/endpoints/api/panel/panellist.go @@ -15,26 +15,28 @@ import ( func ListPanels(c *gin.Context) { type panelResponse struct { - PanelId int `json:"panel_id"` - ChannelId uint64 `json:"channel_id,string"` - Title string `json:"title"` - Colour int32 `json:"colour"` - ButtonLabel string `json:"button_label"` - ButtonStyle int `json:"button_style,string"` - EmojiName *string `json:"emoji_name,omitempty"` - EmojiId *uint64 `json:"emoji_id,omitempty,string"` - UseCustomEmoji bool `json:"use_custom_emoji"` - Emote *string `json:"emote,omitempty"` - HasSupportHours bool `json:"has_support_hours"` - IsCurrentlyActive bool `json:"is_currently_active"` - Disabled bool `json:"disabled"` - ForceDisabled bool `json:"force_disabled"` + PanelId int `json:"panel_id"` + ChannelId uint64 `json:"channel_id,string"` + Title string `json:"title"` + Colour int32 `json:"colour"` + ButtonLabel string `json:"button_label"` + ButtonStyle int `json:"button_style,string"` + EmojiName *string `json:"emoji_name,omitempty"` + EmojiId *uint64 `json:"emoji_id,omitempty,string"` + UseCustomEmoji bool `json:"use_custom_emoji"` + Emote *string `json:"emote,omitempty"` + HasSupportHours bool `json:"has_support_hours"` + IsCurrentlyActive bool `json:"is_currently_active"` + Disabled bool `json:"disabled"` + ForceDisabled bool `json:"force_disabled"` + CloseReasons database.PanelCloseReasons `json:"close_reasons"` } guildId := c.Keys["guildid"].(uint64) var panels []database.Panel var activePanelIds []int + var closeReasons map[int]database.PanelCloseReasons g, ctx := errgroup.WithContext(c) @@ -50,6 +52,12 @@ func ListPanels(c *gin.Context) { return err }) + g.Go(func() error { + var err error + closeReasons, err = dbclient.Client.PanelCloseReasons.GetAllForGuild(ctx, guildId) + return err + }) + if err := g.Wait(); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to load panels")) return @@ -110,6 +118,12 @@ func ListPanels(c *gin.Context) { wrapped := make([]panelResponse, len(panels)) for i, p := range panels { hasSH := supportHoursSet[p.PanelId] + + panelCloseReasons, ok := closeReasons[p.PanelId] + if !ok { + panelCloseReasons = database.DefaultPanelCloseReasons() + } + wrapped[i] = panelResponse{ PanelId: p.PanelId, ChannelId: p.ChannelId, @@ -125,6 +139,7 @@ func ListPanels(c *gin.Context) { IsCurrentlyActive: !hasSH || activeSet[p.PanelId], Disabled: p.Disabled, ForceDisabled: p.ForceDisabled, + CloseReasons: panelCloseReasons, } } diff --git a/backend/app/http/endpoints/api/panel/panelupdate.go b/backend/app/http/endpoints/api/panel/panelupdate.go index 97007d2f..4df6ec6e 100644 --- a/backend/app/http/endpoints/api/panel/panelupdate.go +++ b/backend/app/http/endpoints/api/panel/panelupdate.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strconv" "github.com/TicketsBot-cloud/common/featureflags" @@ -81,6 +82,15 @@ func UpdatePanel(c *gin.Context) { return } + var existingCloseReasons database.PanelCloseReasons + if data.CloseReasons != nil { + existingCloseReasons, err = dbclient.Client.PanelCloseReasons.Get(c, panelId) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to load panel")) + return + } + } + if existing.ForceDisabled { c.JSON(400, utils.ErrorStr("This panel is disabled and cannot be modified: please reactivate premium to re-enable it")) return @@ -88,6 +98,10 @@ func UpdatePanel(c *gin.Context) { // Apply defaults ApplyPanelDefaults(&data) + normaliseCloseReasons(&data) + + closeReasonsChanged := data.CloseReasons != nil && + (data.CloseReasons.AllowCustom != existingCloseReasons.AllowCustom || !slices.Equal(data.CloseReasons.Reasons, existingCloseReasons.Reasons)) premiumTier, err := rpc.PremiumClient.GetTierByGuildId(c, guildId, true, botContext.Token, botContext.RateLimiter) if err != nil { @@ -371,6 +385,12 @@ func UpdatePanel(c *gin.Context) { } } + if closeReasonsChanged { + if err := dbclient.Client.PanelCloseReasons.SetWithTx(c, tx, panel.PanelId, *data.CloseReasons); err != nil { + return err + } + } + if err := dbclient.Client.PanelKBCategories.SetWithTx(c, tx, panel.PanelId, data.KBCategoryIds); err != nil { return err } @@ -475,6 +495,16 @@ func UpdatePanel(c *gin.Context) { } } + metadata := map[string]any{ + "access_control_list_old": existingAcl, + "access_control_list_new": data.AccessControlList, + } + + if closeReasonsChanged { + metadata["close_reasons_old"] = existingCloseReasons + metadata["close_reasons_new"] = data.CloseReasons + } + audit.Log(audit.LogEntry{ GuildId: audit.Uint64Ptr(guildId), UserId: userId, @@ -483,10 +513,7 @@ func UpdatePanel(c *gin.Context) { ResourceId: audit.StringPtr(strconv.Itoa(panelId)), OldData: existing, NewData: panel, - Metadata: map[string]any{ - "access_control_list_old": existingAcl, - "access_control_list_new": data.AccessControlList, - }, + Metadata: metadata, }) c.JSON(200, utils.SuccessResponse) diff --git a/backend/app/http/endpoints/api/panel/validation.go b/backend/app/http/endpoints/api/panel/validation.go index aa842a6f..7435ad71 100644 --- a/backend/app/http/endpoints/api/panel/validation.go +++ b/backend/app/http/endpoints/api/panel/validation.go @@ -41,6 +41,21 @@ func DefaultApplicators(data *panelBody) []defaults.DefaultApplicator { } } +func normaliseCloseReasons(data *panelBody) { + if data.CloseReasons == nil { + return + } + + reasons := make([]string, 0, len(data.CloseReasons.Reasons)) + for _, reason := range data.CloseReasons.Reasons { + if reason = strings.TrimSpace(reason); reason != "" { + reasons = append(reasons, reason) + } + } + + data.CloseReasons.Reasons = reasons +} + const ( MentionBehaviourNone = "none" MentionBehaviourHide = "hide" @@ -82,6 +97,7 @@ func panelValidators() []validation.Validator[PanelValidationContext] { validateNamingScheme, validateWelcomeMessage, validateAccessControlList, + validateCloseReasons, validatePendingCategory, validateTranscriptChannelId, validateTicketNotificationChannel, @@ -438,6 +454,38 @@ func validateAccessControlList(ctx PanelValidationContext) validation.Validation } } +func validateCloseReasons(ctx PanelValidationContext) validation.ValidationFunc { + return func() error { + if ctx.Data.CloseReasons == nil { + return nil + } + + reasons := ctx.Data.CloseReasons.Reasons + + if len(reasons) > 25 { + return validation.NewInvalidInputError("Panel cannot have more than 25 close reasons") + } + + for i, reason := range reasons { + if utf8.RuneCountInString(reason) > 100 { + return validation.NewInvalidInputError("Close reasons must be 100 characters or fewer") + } + + if len(reason) > 255 { + return validation.NewInvalidInputErrorf("Close reason \"%s\" is too long", reason) + } + + for _, previous := range reasons[:i] { + if strings.EqualFold(previous, reason) { + return validation.NewInvalidInputErrorf("Duplicate close reason \"%s\"", reason) + } + } + } + + return nil + } +} + func validateEmbed(e *types.CustomEmbed) error { if e == nil { return nil diff --git a/backend/app/http/endpoints/api/ticket/bulkcloserequest.go b/backend/app/http/endpoints/api/ticket/bulkcloserequest.go index 50ec21c9..32d8568d 100644 --- a/backend/app/http/endpoints/api/ticket/bulkcloserequest.go +++ b/backend/app/http/endpoints/api/ticket/bulkcloserequest.go @@ -63,6 +63,11 @@ func BulkCloseRequest(c *gin.Context) { return } + var panelCloseReasons map[int]dbmodel.PanelCloseReasons + if body.Reason != nil { + panelCloseReasons = getGuildCloseReasons(c, guildId, *body.Reason) + } + var closeAt *time.Time if body.CloseDelay != nil && *body.CloseDelay > 0 { t := time.Now().Add(time.Hour * time.Duration(*body.CloseDelay)) @@ -79,20 +84,35 @@ func BulkCloseRequest(c *gin.Context) { locale := utils.ResolveGuildLocale(context.Background(), guildId) msgEmbed, components := buildCloseRequestMessage(locale, userId, body.Reason, closeAt) - sendOne := func(opCtx context.Context, ticketId int) bool { + sendOne := func(opCtx context.Context, ticketId int) (bool, string) { + failed := fmt.Sprintf("Failed to send close request for ticket #%d", ticketId) + ticket, err := database.Client.Tickets.Get(opCtx, ticketId, guildId) if err != nil || ticket.UserId == 0 { - return false + return false, failed } hasPermission, requestErr := utils.HasPermissionToViewTicket(opCtx, guildId, userId, ticket) if requestErr != nil || !hasPermission { - return false + return false, failed } hasContentPermission, contentErr := utils.HasPermissionToViewTicketContent(opCtx, guildId, userId, ticket) if contentErr != nil || !hasContentPermission { - return false + return false, failed + } + + reason, ticketEmbed := body.Reason, msgEmbed + if body.Reason != nil { + canonical, ok := resolveCloseReasonFrom(panelCloseReasons, ticket, *body.Reason) + if !ok { + return false, closeReasonNotPredefined + } + + if canonical != *body.Reason { + reason = &canonical + ticketEmbed, _ = buildCloseRequestMessage(locale, userId, reason, closeAt) + } } closeReq := dbmodel.CloseRequest{ @@ -100,30 +120,30 @@ func BulkCloseRequest(c *gin.Context) { TicketId: ticketId, UserId: userId, CloseAt: closeAt, - Reason: body.Reason, + Reason: reason, } if err := database.Client.CloseRequest.Set(opCtx, closeReq); err != nil { _ = app.NewError(err, fmt.Sprintf("Failed to save close request for ticket #%d", ticketId)) - return false + return false, failed } if err := database.Client.Tickets.SetStatus(opCtx, guildId, ticketId, model.TicketStatusPending); err != nil { _ = app.NewError(err, fmt.Sprintf("Failed to update status for ticket #%d", ticketId)) - return false + return false, failed } if !ticket.IsThread { if err := database.Client.CategoryUpdateQueue.Add(opCtx, guildId, ticketId, model.TicketStatusPending); err != nil { _ = app.NewError(err, fmt.Sprintf("Failed to queue category update for ticket #%d", ticketId)) - return false + return false, failed } } if ticket.ChannelId != nil { _, _ = rest.CreateMessage(opCtx, botCtx.Token, botCtx.RateLimiter, *ticket.ChannelId, rest.CreateMessageData{ Content: fmt.Sprintf("<@%d>", ticket.UserId), - Embeds: []*embed.Embed{msgEmbed}, + Embeds: []*embed.Embed{ticketEmbed}, AllowedMentions: messagetypes.AllowedMention{ Users: []uint64{ticket.UserId}, }, @@ -137,9 +157,9 @@ func BulkCloseRequest(c *gin.Context) { ActionType: dbmodel.AuditActionTicketCloseRequest, ResourceType: dbmodel.AuditResourceTicket, ResourceId: audit.StringPtr(strconv.Itoa(ticketId)), - Metadata: map[string]interface{}{"reason": body.Reason, "bulk": true}, + Metadata: map[string]interface{}{"reason": reason, "bulk": true}, }) - return true + return true, "" } var backgroundIds []int @@ -150,10 +170,10 @@ func BulkCloseRequest(c *gin.Context) { break } - if sendOne(c, ticketId) { + if ok, msg := sendOne(c, ticketId); ok { result.Sent = append(result.Sent, ticketId) } else { - result.Failed[strconv.Itoa(ticketId)] = fmt.Sprintf("Failed to send close request for ticket #%d", ticketId) + result.Failed[strconv.Itoa(ticketId)] = msg } if i < len(body.TicketIds)-1 && !time.Now().After(deadline) { diff --git a/backend/app/http/endpoints/api/ticket/bulkclosetickets.go b/backend/app/http/endpoints/api/ticket/bulkclosetickets.go index 7a441d22..a370741a 100644 --- a/backend/app/http/endpoints/api/ticket/bulkclosetickets.go +++ b/backend/app/http/endpoints/api/ticket/bulkclosetickets.go @@ -48,6 +48,8 @@ func BulkCloseTickets(c *gin.Context) { return } + panelCloseReasons := getGuildCloseReasons(c, guildId, body.Reason) + result := bulkCloseResult{ Closed: []int{}, Failed: map[string]string{}, @@ -55,27 +57,34 @@ func BulkCloseTickets(c *gin.Context) { deadline := time.Now().Add(bulkTimeoutSeconds * time.Second) - closeOne := func(opCtx context.Context, ticketId int) bool { + closeOne := func(opCtx context.Context, ticketId int) (bool, string) { + failed := fmt.Sprintf("Failed to close ticket #%d", ticketId) + ticket, err := database.Client.Tickets.Get(opCtx, ticketId, guildId) if err != nil || ticket.UserId == 0 { - return false + return false, failed } hasPermission, requestErr := utils.HasPermissionToViewTicket(opCtx, guildId, userId, ticket) if requestErr != nil || !hasPermission { - return false + return false, failed + } + + reason, ok := resolveCloseReasonFrom(panelCloseReasons, ticket, body.Reason) + if !ok { + return false, closeReasonNotPredefined } data := closerelay.TicketClose{ GuildId: guildId, TicketId: ticket.Id, UserId: userId, - Reason: body.Reason, + Reason: reason, } if err := closerelay.Publish(redis.Client.Client, data); err != nil { _ = app.NewError(err, fmt.Sprintf("Failed to publish close event for ticket #%d", ticketId)) - return false + return false, failed } audit.Log(audit.LogEntry{ @@ -86,7 +95,7 @@ func BulkCloseTickets(c *gin.Context) { ResourceId: audit.StringPtr(strconv.Itoa(ticketId)), Metadata: map[string]interface{}{"reason": data.Reason, "bulk": true}, }) - return true + return true, "" } var backgroundIds []int @@ -97,10 +106,10 @@ func BulkCloseTickets(c *gin.Context) { break } - if closeOne(c, ticketId) { + if ok, msg := closeOne(c, ticketId); ok { result.Closed = append(result.Closed, ticketId) } else { - result.Failed[strconv.Itoa(ticketId)] = fmt.Sprintf("Failed to close ticket #%d", ticketId) + result.Failed[strconv.Itoa(ticketId)] = msg } if i < len(body.TicketIds)-1 && !time.Now().After(deadline) { diff --git a/backend/app/http/endpoints/api/ticket/closerequest.go b/backend/app/http/endpoints/api/ticket/closerequest.go index 4d569eb1..3c40e27e 100644 --- a/backend/app/http/endpoints/api/ticket/closerequest.go +++ b/backend/app/http/endpoints/api/ticket/closerequest.go @@ -84,6 +84,16 @@ func CloseRequest(c *gin.Context) { return } + if body.Reason != nil { + reason, ok := resolveCloseReason(c, ticket, *body.Reason) + if !ok { + c.JSON(http.StatusBadRequest, utils.ErrorStr(closeReasonNotPredefined)) + return + } + + body.Reason = &reason + } + botCtx, err := botcontext.ContextForGuild(guildId) if err != nil { c.JSON(http.StatusInternalServerError, utils.ErrorStr("Unable to connect to Discord. Please try again later.")) diff --git a/backend/app/http/endpoints/api/ticket/closeticket.go b/backend/app/http/endpoints/api/ticket/closeticket.go index f135c253..950a8c6a 100644 --- a/backend/app/http/endpoints/api/ticket/closeticket.go +++ b/backend/app/http/endpoints/api/ticket/closeticket.go @@ -61,11 +61,17 @@ func CloseTicket(c *gin.Context) { return } + reason, ok := resolveCloseReason(c, ticket, body.Reason) + if !ok { + c.JSON(http.StatusBadRequest, utils.ErrorStr(closeReasonNotPredefined)) + return + } + data := closerelay.TicketClose{ GuildId: guildId, TicketId: ticket.Id, UserId: userId, - Reason: body.Reason, + Reason: reason, } if err := closerelay.Publish(redis.Client.Client, data); err != nil { diff --git a/backend/app/http/endpoints/api/ticket/getticket.go b/backend/app/http/endpoints/api/ticket/getticket.go index 0e424b90..11f53915 100644 --- a/backend/app/http/endpoints/api/ticket/getticket.go +++ b/backend/app/http/endpoints/api/ticket/getticket.go @@ -90,11 +90,16 @@ func GetTicket(c *gin.Context) { } var panelTitle *string + closeReasons := database.DefaultPanelCloseReasons() if ticket.PanelId != nil { panel, err := dbclient.Client.Panel.GetById(c, *ticket.PanelId) if err == nil { panelTitle = &panel.Title } + + if reasons, err := dbclient.Client.PanelCloseReasons.Get(c, *ticket.PanelId); err == nil { + closeReasons = reasons + } } claimedById, _ := dbclient.Client.TicketClaims.Get(c, guildId, ticketId) @@ -134,6 +139,7 @@ func GetTicket(c *gin.Context) { "success": true, "ticket": ticketData, "panel_title": panelTitle, + "close_reasons": closeReasons, "messages": messages, "content_restricted": contentRestricted, "channel_missing": channelMissing, diff --git a/backend/app/http/endpoints/api/ticket/panelclosereasons.go b/backend/app/http/endpoints/api/ticket/panelclosereasons.go new file mode 100644 index 00000000..dbb33a65 --- /dev/null +++ b/backend/app/http/endpoints/api/ticket/panelclosereasons.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "strings" + + dbmodel "github.com/TicketsBot-cloud/database" + "github.com/ticketsbot-cloud/dashboard/backend/database" + "github.com/ticketsbot-cloud/dashboard/backend/log" + "go.uber.org/zap" +) + +const closeReasonNotPredefined = "Close reason must be one of the panel's predefined close reasons" + +func resolveCloseReason(ctx context.Context, ticket dbmodel.Ticket, reason string) (string, bool) { + if strings.TrimSpace(reason) == "" || ticket.PanelId == nil { + return reason, true + } + + closeReasons, err := database.Client.PanelCloseReasons.Get(ctx, *ticket.PanelId) + if err != nil { + log.Logger.Error("Failed to load panel close reasons", zap.Int("panel_id", *ticket.PanelId), zap.Error(err)) + return reason, true + } + + return closeReasons.Resolve(reason) +} + +func getGuildCloseReasons(ctx context.Context, guildId uint64, reason string) map[int]dbmodel.PanelCloseReasons { + if strings.TrimSpace(reason) == "" { + return nil + } + + closeReasons, err := database.Client.PanelCloseReasons.GetAllForGuild(ctx, guildId) + if err != nil { + log.Logger.Error("Failed to load panel close reasons", zap.Uint64("guild_id", guildId), zap.Error(err)) + return nil + } + + return closeReasons +} + +func resolveCloseReasonFrom(panelCloseReasons map[int]dbmodel.PanelCloseReasons, ticket dbmodel.Ticket, reason string) (string, bool) { + if ticket.PanelId == nil { + return reason, true + } + + closeReasons, ok := panelCloseReasons[*ticket.PanelId] + if !ok { + return reason, true + } + + return closeReasons.Resolve(reason) +} diff --git a/backend/app/http/endpoints/api/ticket/updateclosereason.go b/backend/app/http/endpoints/api/ticket/updateclosereason.go index 3931e2d8..a68608e8 100644 --- a/backend/app/http/endpoints/api/ticket/updateclosereason.go +++ b/backend/app/http/endpoints/api/ticket/updateclosereason.go @@ -69,13 +69,18 @@ func UpdateCloseReason(c *gin.Context) { return } + reason, ok := resolveCloseReason(c, ticket, body.Reason) + if !ok { + c.JSON(http.StatusBadRequest, utils.ErrorStr(closeReasonNotPredefined)) + return + } + existing, _, err := database.Client.CloseReason.Get(c, guildId, ticketId) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to load close reason. Please try again.")) return } - reason := body.Reason oldReason := "" if existing.Reason != nil { oldReason = *existing.Reason diff --git a/backend/app/http/endpoints/api/transcripts/list.go b/backend/app/http/endpoints/api/transcripts/list.go index 036736d0..3159c73a 100644 --- a/backend/app/http/endpoints/api/transcripts/list.go +++ b/backend/app/http/endpoints/api/transcripts/list.go @@ -22,6 +22,7 @@ type transcriptLabelData struct { type transcriptMetadata struct { TicketId int `json:"ticket_id"` + PanelId *int `json:"panel_id"` Username string `json:"username"` CloseReason *string `json:"close_reason"` ClosedBy *uint64 `json:"closed_by"` @@ -169,6 +170,7 @@ func ListTranscripts(ctx *gin.Context) { for i, ticket := range tickets { transcript := transcriptMetadata{ TicketId: ticket.Id, + PanelId: ticket.PanelId, Username: usernames[ticket.UserId], HasTranscript: ticket.HasTranscript, } diff --git a/backend/go.mod b/backend/go.mod index a82fb5d4..3b88830e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,7 @@ module github.com/ticketsbot-cloud/dashboard/backend go 1.26.0 -// replace github.com/TicketsBot-cloud/database => ../../database +replace github.com/TicketsBot-cloud/database => ../../database // replace github.com/TicketsBot-cloud/common => ../../common diff --git a/frontend/src/components/CloseReasonSelect.tsx b/frontend/src/components/CloseReasonSelect.tsx new file mode 100644 index 00000000..1c5b673b --- /dev/null +++ b/frontend/src/components/CloseReasonSelect.tsx @@ -0,0 +1,85 @@ +import { useState, type FC } from "react"; +import Select from "@/components/Select"; +import Textarea from "@/components/Textarea"; +import { matchCloseReason } from "@/lib/close-reasons"; +import type { PanelCloseReasons } from "@/types"; + +interface CloseReasonSelectProps { + closeReasons: PanelCloseReasons; + value: string; + onChange: (reason: string) => void; + label: string; + hideLabel?: boolean; + className?: string; + max: number; +} + +const CUSTOM_KEY = "custom"; +// Matches no option, so Select falls back to the "Current: …" placeholder. +const CURRENT_KEY = "current"; + +const CloseReasonSelect: FC = ({ + closeReasons, + value, + onChange, + label, + hideLabel, + className = "", + max, +}) => { + const { reasons, allow_custom: allowCustom } = closeReasons; + const [customPicked, setCustomPicked] = useState( + () => !!value.trim() && matchCloseReason(reasons, value) === undefined, + ); + const custom = allowCustom && customPicked; + + const preset = matchCloseReason(reasons, value); + const selected = custom + ? CUSTOM_KEY + : preset !== undefined + ? String(reasons.indexOf(preset)) + : value.trim() + ? CURRENT_KEY + : null; + + const options = reasons.map((reason, i) => ({ key: String(i), label: reason })); + if (allowCustom) options.push({ key: CUSTOM_KEY, label: "Custom reason" }); + + return ( +
+