// Package ticket_service handles support tickets. package ticket_service import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "net/url" "strings" "time" "evening_detective_server/internal/repos/tickets_repo" ) const maxTicketsPerRequest = 100 var ( // ErrInvalidTicketCount indicates that the requested ticket count is invalid. ErrInvalidTicketCount = errors.New("количество билетов должно быть от 1 до 100") // ErrInvalidBaseURL indicates that the public application URL is missing. ErrInvalidBaseURL = errors.New("необходимо настроить APP_BASE_URL — публичный адрес приложения") // ErrTicketNotFound indicates that a ticket was not found. ErrTicketNotFound = errors.New("Билет не найден") // ErrTicketUsed indicates that a ticket was already used. ErrTicketUsed = errors.New("Билет уже использован") // ErrGameFinished indicates that the game has finished. ErrGameFinished = errors.New("Игра уже завершена") ) // Ticket describes an issued support ticket. type Ticket struct { ID int URL string } // Page contains tickets returned for a user. type Page struct { ID int Status string GameName string StartAt time.Time Location *string GameStatus string UsedAt *time.Time UsedBy *string } // TicketService handles support tickets. type TicketService struct { repo *tickets_repo.Repo appBaseURL string } // NewTicketService creates a ticket service. func NewTicketService(repo *tickets_repo.Repo, appBaseURL string) *TicketService { return &TicketService{repo: repo, appBaseURL: appBaseURL} } // Issue creates tickets for a game. func (s *TicketService) Issue(ctx context.Context, gameID, count int) ([]*Ticket, error) { if count < 1 || count > maxTicketsPerRequest { return nil, ErrInvalidTicketCount } base, err := parseBaseURL(s.appBaseURL) if err != nil { return nil, err } hashes := make([][]byte, count) tokens := make([]string, count) for i := range count { token, err := newToken() if err != nil { return nil, err } hash := sha256.Sum256([]byte(token)) tokens[i] = token hashes[i] = hash[:] } ids, err := s.repo.CreateBatch(ctx, gameID, hashes) if err != nil { return nil, err } tickets := make([]*Ticket, count) for i, id := range ids { link := *base link.Path = strings.TrimRight(base.Path, "/") + "/tickets/" + tokens[i] link.RawPath = "" tickets[i] = &Ticket{ID: id, URL: link.String()} } return tickets, nil } // Get returns tickets associated with a token. func (s *TicketService) Get(ctx context.Context, token string) (*Page, error) { hash, err := tokenHash(token) if err != nil { return nil, ErrTicketNotFound } info, err := s.repo.GetByHash(ctx, hash) if err != nil { return nil, mapTicketError(err) } return &Page{ ID: info.ID, Status: info.Status, GameName: info.GameName, StartAt: info.StartAt, Location: info.Location, GameStatus: info.GameStatus, UsedAt: info.UsedAt, UsedBy: info.UsedBy, }, nil } // Accept assigns a ticket to a user. func (s *TicketService) Accept(ctx context.Context, token string, userID int) (*Page, error) { hash, err := tokenHash(token) if err != nil { return nil, ErrTicketNotFound } info, err := s.repo.Accept(ctx, hash, userID) if err != nil { return nil, mapTicketError(err) } return &Page{ID: info.ID, Status: info.Status, GameName: info.GameName, StartAt: info.StartAt, Location: info.Location, GameStatus: info.GameStatus, UsedAt: info.UsedAt, UsedBy: info.UsedBy}, nil } func mapTicketError(err error) error { switch { case errors.Is(err, tickets_repo.ErrTicketNotFound): return ErrTicketNotFound case errors.Is(err, tickets_repo.ErrTicketUsed): return ErrTicketUsed case errors.Is(err, tickets_repo.ErrGameFinished): return ErrGameFinished default: return err } } func newToken() (string, error) { data := make([]byte, 32) if _, err := rand.Read(data); err != nil { return "", fmt.Errorf("generate ticket token: %w", err) } return base64.RawURLEncoding.EncodeToString(data), nil } func tokenHash(token string) ([]byte, error) { data, err := base64.RawURLEncoding.DecodeString(token) if err != nil || len(data) != 32 { return nil, ErrTicketNotFound } hash := sha256.Sum256([]byte(token)) return hash[:], nil } func parseBaseURL(value string) (*url.URL, error) { base, err := url.Parse(strings.TrimSpace(value)) if err != nil || base.Host == "" || (base.Scheme != "http" && base.Scheme != "https") || base.User != nil || base.RawQuery != "" || base.Fragment != "" { return nil, ErrInvalidBaseURL } return base, nil }