178 lines
4.1 KiB
Go
178 lines
4.1 KiB
Go
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
|
|||
|
|
}
|