Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
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
|
||
}
|