package support import ( "context" "errors" "fmt" "log" "strconv" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // SubmitCSAT records a one-time 1–5 rating from the ticket owner. // Logs ticket_id + score only — never comment, email, or other PII. func (s *Service) SubmitCSAT(ctx context.Context, companyID, userID, ticketID uuid.UUID, in CSATInput) (CSATRating, error) { score, err := normalizeCSATScore(in.Score) if err != nil { return CSATRating{}, err } comment, err := normalizeCSATComment(in.Comment) if err != nil { return CSATRating{}, err } tx, err := s.Pool.Begin(ctx) if err != nil { return CSATRating{}, err } defer func() { _ = tx.Rollback(ctx) }() var status string var ownerID uuid.UUID err = tx.QueryRow(ctx, ` SELECT status, created_by_user_id FROM support_tickets WHERE id = $1 AND company_id = $2 FOR UPDATE`, ticketID, companyID, ).Scan(&status, &ownerID) if errors.Is(err, pgx.ErrNoRows) { return CSATRating{}, ErrNotFound } if err != nil { return CSATRating{}, err } if ownerID != userID { return CSATRating{}, ErrNotFound } if status != "resolved" && status != "closed" { return CSATRating{}, ErrCSATNotEligible } now := time.Now().UTC() var out CSATRating err = tx.QueryRow(ctx, ` INSERT INTO support_csat_ratings (ticket_id, company_id, user_id, score, comment, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING score, comment, created_at`, ticketID, companyID, userID, score, comment, now, ).Scan(&out.Score, &out.Comment, &out.CreatedAt) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return CSATRating{}, ErrAlreadyRated } return CSATRating{}, err } if err := tx.Commit(ctx); err != nil { return CSATRating{}, err } log.Printf("support: csat submitted ticket_id=%s score=%d", ticketID, out.Score) return out, nil } // AggregateCSAT returns platform-wide CSAT stats for admins (no PII). func (s *Service) AggregateCSAT(ctx context.Context, from, to *time.Time) (CSATAggregate, error) { args := make([]any, 0, 2) where := `TRUE` if from != nil { args = append(args, from.UTC()) where += fmt.Sprintf(` AND created_at >= $%d`, len(args)) } if to != nil { args = append(args, to.UTC()) where += fmt.Sprintf(` AND created_at < $%d`, len(args)) } var total int64 var sum float64 err := s.Pool.QueryRow(ctx, ` SELECT count(*), COALESCE(sum(score), 0) FROM support_csat_ratings WHERE `+where, args...).Scan(&total, &sum) if err != nil { return CSATAggregate{}, err } dist := map[string]int64{"1": 0, "2": 0, "3": 0, "4": 0, "5": 0} rows, err := s.Pool.Query(ctx, ` SELECT score, count(*) FROM support_csat_ratings WHERE `+where+` GROUP BY score`, args...) if err != nil { return CSATAggregate{}, err } defer rows.Close() for rows.Next() { var score int var n int64 if err := rows.Scan(&score, &n); err != nil { return CSATAggregate{}, err } if score >= 1 && score <= 5 { dist[strconv.Itoa(score)] = n } } if err := rows.Err(); err != nil { return CSATAggregate{}, err } avg := 0.0 if total > 0 { avg = sum / float64(total) } out := CSATAggregate{ Total: total, Average: avg, Distribution: dist, } if from != nil { t := from.UTC() out.From = &t } if to != nil { t := to.UTC() out.To = &t } return out, nil } func (s *Service) getCSATByTicket(ctx context.Context, ticketID uuid.UUID) (*CSATRating, error) { var out CSATRating err := s.Pool.QueryRow(ctx, ` SELECT score, comment, created_at FROM support_csat_ratings WHERE ticket_id = $1`, ticketID, ).Scan(&out.Score, &out.Comment, &out.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &out, nil } func (s *Service) attachCSAT(ctx context.Context, t *Ticket, forCustomer bool) error { rating, err := s.getCSATByTicket(ctx, t.ID) if err != nil { if IsMissingRelation(err) { return nil } return err } if rating != nil { t.CSAT = rating t.CSATEligible = false return nil } if forCustomer { t.CSATEligible = t.Status == "resolved" || t.Status == "closed" } return nil }