Files
descrybe/apps/api/internal/support/ratings.go
T

178 lines
4.1 KiB
Go
Raw Normal View History

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 15 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
}