package tickets import ( "context" "fmt" "log/slog" "strings" "sync" "time" "unicode" "github.com/bwmarrin/discordgo" "github.com/leolionad58/ticketbot/internal/config" "github.com/leolionad58/ticketbot/internal/db" ) type Service struct { db *db.TicketRepo panelRepo *db.PanelConfigRepo auth *AuthService session *discordgo.Session cfg *config.Provider opening sync.Map // key: userID+":"+ticketType prevents double-click } func NewService(repo *db.TicketRepo, panelRepo *db.PanelConfigRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service { return &Service{db: repo, panelRepo: panelRepo, auth: auth, session: s, cfg: cfg} } var ErrAlreadyOpen = fmt.Errorf("already_open") // Open creates a ticket channel and inserts into DB. Returns the created Ticket. // Returns ErrAlreadyOpen (wrapped with channel ID) if user already has an open ticket of the same type. func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticketType string) (*db.Ticket, error) { lockKey := userID + ":" + ticketType if _, loaded := svc.opening.LoadOrStore(lockKey, struct{}{}); loaded { return nil, fmt.Errorf("creation already in progress") } defer svc.opening.Delete(lockKey) existing, err := svc.db.HasOpenTicket(ctx, userID, ticketType) if err != nil { return nil, fmt.Errorf("check existing: %w", err) } if existing != nil { return existing, fmt.Errorf("%w:%s", ErrAlreadyOpen, existing.ChannelID) } var categoryID, claimChannelID, logChannelID, staffRoleID string var claimReupMinutes int yamlCfg := svc.cfg.Get() if panel, ok := yamlCfg.Panels[panelName]; ok { typeCfg, ok := panel.Types[ticketType] if !ok { return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName) } categoryID = typeCfg.Category staffRoleID = typeCfg.StaffRole // YAML panels have no per-type claim/log routing; fall back to global config at claim/log time } else if svc.panelRepo != nil { dbPanel, err := svc.panelRepo.GetByName(ctx, panelName) if err != nil || dbPanel == nil { return nil, fmt.Errorf("panel %q not found", panelName) } var dbType *db.PanelType for _, t := range dbPanel.Types { if t.Name == ticketType { dbType = t break } } if dbType == nil { return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName) } categoryID = dbType.CategoryID claimChannelID = dbType.ClaimChannelID logChannelID = dbType.LogChannelID staffRoleID = dbType.StaffRoleID claimReupMinutes = dbType.ClaimReupMinutes } else { return nil, fmt.Errorf("panel %q not found", panelName) } // Reserve a placeholder to get the ticket number atomically ticket := &db.Ticket{ UserID: userID, Panel: panelName, Type: ticketType, ChannelID: fmt.Sprintf("pending-%d", time.Now().UnixNano()), GuildID: guildID, ClaimChannelID: claimChannelID, LogChannelID: logChannelID, StaffRoleID: staffRoleID, ClaimReupMinutes: claimReupMinutes, OpenedAt: time.Now(), } if err := svc.db.Insert(ctx, ticket); err != nil { return nil, fmt.Errorf("insert ticket: %w", err) } channelName := fmt.Sprintf("%s-%04d", ticketType, ticket.TicketNumber) ch, err := svc.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ Name: channelName, Type: discordgo.ChannelTypeGuildText, ParentID: categoryID, PermissionOverwrites: []*discordgo.PermissionOverwrite{ { ID: guildID, Type: discordgo.PermissionOverwriteTypeRole, Deny: discordgo.PermissionViewChannel, }, { ID: userID, Type: discordgo.PermissionOverwriteTypeMember, Allow: discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionReadMessageHistory | discordgo.PermissionAttachFiles, }, }, }) if err != nil { slog.Error("create channel", "ticket_id", ticket.ID, "err", err) return nil, fmt.Errorf("create channel: %w", err) } if err := svc.db.UpdateChannelID(ctx, ticket.ID, ch.ID); err != nil { slog.Error("update channel_id", "ticket_id", ticket.ID, "err", err) } ticket.ChannelID = ch.ID return ticket, nil } // AddMember adds a Discord user to a ticket channel with full read/send permissions. func (svc *Service) AddMember(ctx context.Context, channelID, userID string) error { return svc.session.ChannelPermissionSet(channelID, userID, discordgo.PermissionOverwriteTypeMember, discordgo.PermissionViewChannel|discordgo.PermissionSendMessages| discordgo.PermissionReadMessageHistory|discordgo.PermissionAttachFiles, 0) } // RemoveMember removes a Discord user from a ticket channel. func (svc *Service) RemoveMember(ctx context.Context, channelID, userID string) error { return svc.session.ChannelPermissionDelete(channelID, userID) } // Rename sanitizes and renames a ticket channel. Returns error if name is invalid. func (svc *Service) Rename(ctx context.Context, channelID, name string) error { safe := sanitizeChannelName(name) if safe == "" { return fmt.Errorf("invalid channel name after sanitization") } if len(safe) > 100 { safe = safe[:100] } _, err := svc.session.ChannelEdit(channelID, &discordgo.ChannelEdit{Name: safe}) return err } // ClaimChannel adds a staff member to a ticket channel. func (svc *Service) ClaimChannel(ctx context.Context, channelID, staffID string) error { return svc.AddMember(ctx, channelID, staffID) } // DeleteChannel permanently deletes a Discord channel. func (svc *Service) DeleteChannel(channelID string) error { _, err := svc.session.ChannelDelete(channelID) return err } func sanitizeChannelName(name string) string { var b strings.Builder for _, r := range strings.ToLower(name) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': b.WriteRune(r) case unicode.IsSpace(r): b.WriteRune('-') } } return strings.Trim(b.String(), "-") }