Files
descrybe/apps/api/internal/support/ratings.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

178 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}