package repository import ( "context" "encoding/json" "errors" "time" "unicode/utf8" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // QualityFeedbackRow is AskOperations QualityFeedback. type QualityFeedbackRow struct { ID uuid.UUID `json:"id"` ThreadID uuid.UUID `json:"thread_id"` MessageID *uuid.UUID `json:"message_id,omitempty"` Source string `json:"source"` Rating int `json:"rating"` Tag *string `json:"tag,omitempty"` Note *string `json:"note,omitempty"` CreatedByAdmin *uuid.UUID `json:"created_by_admin,omitempty"` CreatedByUser *uuid.UUID `json:"created_by_user,omitempty"` CreatedAt time.Time `json:"created_at"` } // ListQualityFeedback returns recent feedback rows. func (r *AdminRepo) ListQualityFeedback(ctx context.Context, limit, offset int) ([]QualityFeedbackRow, error) { if limit <= 0 || limit > 100 { limit = 20 } if offset < 0 { offset = 0 } rows, err := r.Pool.Query(ctx, ` SELECT id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at FROM ask_quality_feedback ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset) if err != nil { return nil, err } defer rows.Close() return scanQualityFeedback(rows) } func scanQualityFeedback(rows pgx.Rows) ([]QualityFeedbackRow, error) { var out []QualityFeedbackRow for rows.Next() { var f QualityFeedbackRow if err := rows.Scan( &f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note, &f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt, ); err != nil { return nil, err } out = append(out, f) } return out, rows.Err() } // CreateAdminQualityFeedback inserts ops feedback + audit. func (r *AdminRepo) CreateAdminQualityFeedback( ctx context.Context, adminID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note *string, ) (*QualityFeedbackRow, error) { if err := validateFeedback(rating, tag, note); err != nil { return nil, err } tag, note = cleanTag(tag), cleanNote(note) ok, err := r.askThreadExists(ctx, threadID) if err != nil { return nil, err } if !ok { return nil, errors.New("ask thread not found") } if messageID != nil { inThread, err := r.askMessageInThread(ctx, threadID, *messageID) if err != nil { return nil, err } if !inThread { return nil, errors.New("message not in thread") } } tx, err := r.Pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var f QualityFeedbackRow err = tx.QueryRow(ctx, ` INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_admin) VALUES ($1,$2,'admin',$3,$4,$5,$6) RETURNING id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at`, threadID, messageID, rating, tag, note, adminID, ).Scan(&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note, &f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt) if err != nil { return nil, err } meta, _ := json.Marshal(map[string]any{"rating": rating, "thread_id": threadID.String()}) if _, err := tx.Exec(ctx, ` INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta) VALUES ($1,'ask.feedback.create','ask_thread',$2,$3)`, adminID, threadID.String(), meta); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return &f, nil } // CreateUserQualityFeedback inserts C-end feedback for owned thread. func (r *AskRepo) CreateUserQualityFeedback( ctx context.Context, userID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note *string, ) (*QualityFeedbackRow, error) { if err := validateFeedback(rating, tag, note); err != nil { return nil, err } tag, note = cleanTag(tag), cleanNote(note) _, err := r.GetThreadForUser(ctx, userID, threadID) if err != nil { return nil, errors.New("ask thread not found") } if messageID != nil { var n int err = r.Pool.QueryRow(ctx, ` SELECT 1 FROM ask_messages WHERE id=$1 AND thread_id=$2 AND deleted_at IS NULL`, *messageID, threadID).Scan(&n) if errors.Is(err, pgx.ErrNoRows) { return nil, errors.New("message not in thread") } if err != nil { return nil, err } } var f QualityFeedbackRow err = r.Pool.QueryRow(ctx, ` INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_user) VALUES ($1,$2,'user',$3,$4,$5,$6) RETURNING id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at`, threadID, messageID, rating, tag, note, userID, ).Scan(&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note, &f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt) return &f, err } func (r *AdminRepo) askThreadExists(ctx context.Context, threadID uuid.UUID) (bool, error) { var n int err := r.Pool.QueryRow(ctx, ` SELECT 1 FROM ask_threads WHERE id=$1 AND deleted_at IS NULL`, threadID).Scan(&n) if errors.Is(err, pgx.ErrNoRows) { return false, nil } return err == nil, err } func (r *AdminRepo) askMessageInThread(ctx context.Context, threadID, messageID uuid.UUID) (bool, error) { var n int err := r.Pool.QueryRow(ctx, ` SELECT 1 FROM ask_messages WHERE id=$1 AND thread_id=$2 AND deleted_at IS NULL`, messageID, threadID).Scan(&n) if errors.Is(err, pgx.ErrNoRows) { return false, nil } return err == nil, err } func validateFeedback(rating int, tag, note *string) error { if rating < 1 || rating > 5 { return errors.New("rating must be 1-5") } if tag != nil && *tag != "" { switch *tag { case "helpful", "off_topic", "unsafe", "other": default: return errors.New("invalid tag") } } if note != nil && utf8.RuneCountInString(*note) > 500 { return errors.New("note too long") } return nil } func cleanTag(tag *string) *string { if tag == nil || *tag == "" { return nil } return tag } func cleanNote(note *string) *string { if note == nil || *note == "" { return nil } return note }