1114 lines
33 KiB
Go
1114 lines
33 KiB
Go
package support
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
"github.com/jackc/pgx/v5/pgconn"
|
||
|
|
)
|
||
|
|
|
||
|
|
const ticketListSelectCols = `
|
||
|
|
t.id, t.company_id, t.created_by_user_id, t.subject, t.category, t.status, t.priority,
|
||
|
|
COALESCE(t.tags, '{}'), t.related_product_id, COALESCE(t.related_sku, ''),
|
||
|
|
COALESCE(t.auto_reply_disabled, false), COALESCE(t.auto_reply_status, 'none'),
|
||
|
|
t.assignee_admin_user_id, t.resolved_by_user_id,
|
||
|
|
t.last_message_at, t.last_customer_message_at, t.last_agent_message_at,
|
||
|
|
t.resolved_at, t.closed_at, t.created_at, t.updated_at`
|
||
|
|
|
||
|
|
const ticketDetailSelectCols = ticketListSelectCols + `,
|
||
|
|
COALESCE(t.customer_context, '{}'::jsonb),
|
||
|
|
t.auto_reply_attempted_at, t.auto_reply_message_id,
|
||
|
|
COALESCE(t.auto_reply_meta, '{}'::jsonb)`
|
||
|
|
|
||
|
|
func scanTicketList(row pgx.Row) (Ticket, error) {
|
||
|
|
var t Ticket
|
||
|
|
var tags []string
|
||
|
|
err := row.Scan(
|
||
|
|
&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Subject, &t.Category, &t.Status, &t.Priority,
|
||
|
|
&tags, &t.RelatedProductID, &t.RelatedSKU,
|
||
|
|
&t.AutoReplyDisabled, &t.AutoReplyStatus,
|
||
|
|
&t.AssigneeAdminUserID, &t.ResolvedByUserID,
|
||
|
|
&t.LastMessageAt, &t.LastCustomerMessageAt, &t.LastAgentMessageAt,
|
||
|
|
&t.ResolvedAt, &t.ClosedAt, &t.CreatedAt, &t.UpdatedAt,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return t, err
|
||
|
|
}
|
||
|
|
if tags == nil {
|
||
|
|
tags = []string{}
|
||
|
|
}
|
||
|
|
t.Tags = tags
|
||
|
|
return t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanTicketListWithMeta(row pgx.Row) (Ticket, error) {
|
||
|
|
var t Ticket
|
||
|
|
var tags []string
|
||
|
|
err := row.Scan(
|
||
|
|
&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Subject, &t.Category, &t.Status, &t.Priority,
|
||
|
|
&tags, &t.RelatedProductID, &t.RelatedSKU,
|
||
|
|
&t.AutoReplyDisabled, &t.AutoReplyStatus,
|
||
|
|
&t.AssigneeAdminUserID, &t.ResolvedByUserID,
|
||
|
|
&t.LastMessageAt, &t.LastCustomerMessageAt, &t.LastAgentMessageAt,
|
||
|
|
&t.ResolvedAt, &t.ClosedAt, &t.CreatedAt, &t.UpdatedAt,
|
||
|
|
&t.CompanyName, &t.CreatedByEmail, &t.AssigneeEmail,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return t, err
|
||
|
|
}
|
||
|
|
if tags == nil {
|
||
|
|
tags = []string{}
|
||
|
|
}
|
||
|
|
t.Tags = tags
|
||
|
|
return t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanTicketDetail(row pgx.Row) (Ticket, error) {
|
||
|
|
var t Ticket
|
||
|
|
var tags []string
|
||
|
|
var ctxBytes, metaBytes []byte
|
||
|
|
err := row.Scan(
|
||
|
|
&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Subject, &t.Category, &t.Status, &t.Priority,
|
||
|
|
&tags, &t.RelatedProductID, &t.RelatedSKU,
|
||
|
|
&t.AutoReplyDisabled, &t.AutoReplyStatus,
|
||
|
|
&t.AssigneeAdminUserID, &t.ResolvedByUserID,
|
||
|
|
&t.LastMessageAt, &t.LastCustomerMessageAt, &t.LastAgentMessageAt,
|
||
|
|
&t.ResolvedAt, &t.ClosedAt, &t.CreatedAt, &t.UpdatedAt,
|
||
|
|
&ctxBytes, &t.AutoReplyAttemptedAt, &t.AutoReplyMessageID, &metaBytes,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return t, err
|
||
|
|
}
|
||
|
|
if tags == nil {
|
||
|
|
tags = []string{}
|
||
|
|
}
|
||
|
|
t.Tags = tags
|
||
|
|
t.CustomerContext = json.RawMessage(ctxBytes)
|
||
|
|
t.AutoReplyMeta = json.RawMessage(metaBytes)
|
||
|
|
return t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanTicketDetailWithMeta(row pgx.Row) (Ticket, error) {
|
||
|
|
var t Ticket
|
||
|
|
var tags []string
|
||
|
|
var ctxBytes, metaBytes []byte
|
||
|
|
err := row.Scan(
|
||
|
|
&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Subject, &t.Category, &t.Status, &t.Priority,
|
||
|
|
&tags, &t.RelatedProductID, &t.RelatedSKU,
|
||
|
|
&t.AutoReplyDisabled, &t.AutoReplyStatus,
|
||
|
|
&t.AssigneeAdminUserID, &t.ResolvedByUserID,
|
||
|
|
&t.LastMessageAt, &t.LastCustomerMessageAt, &t.LastAgentMessageAt,
|
||
|
|
&t.ResolvedAt, &t.ClosedAt, &t.CreatedAt, &t.UpdatedAt,
|
||
|
|
&ctxBytes, &t.AutoReplyAttemptedAt, &t.AutoReplyMessageID, &metaBytes,
|
||
|
|
&t.CompanyName, &t.CreatedByEmail, &t.AssigneeEmail,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return t, err
|
||
|
|
}
|
||
|
|
if tags == nil {
|
||
|
|
tags = []string{}
|
||
|
|
}
|
||
|
|
t.Tags = tags
|
||
|
|
t.CustomerContext = json.RawMessage(ctxBytes)
|
||
|
|
t.AutoReplyMeta = json.RawMessage(metaBytes)
|
||
|
|
return t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const ticketSelectColsLegacy = `
|
||
|
|
t.id, t.company_id, t.created_by_user_id, t.subject, t.category, t.status, t.priority,
|
||
|
|
t.assignee_admin_user_id, t.last_message_at, t.last_customer_message_at, t.last_agent_message_at,
|
||
|
|
t.resolved_at, t.closed_at, t.created_at, t.updated_at`
|
||
|
|
|
||
|
|
func scanTicketLegacy(row pgx.Row) (Ticket, error) {
|
||
|
|
var t Ticket
|
||
|
|
err := row.Scan(
|
||
|
|
&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Subject, &t.Category, &t.Status, &t.Priority,
|
||
|
|
&t.AssigneeAdminUserID, &t.LastMessageAt, &t.LastCustomerMessageAt, &t.LastAgentMessageAt,
|
||
|
|
&t.ResolvedAt, &t.ClosedAt, &t.CreatedAt, &t.UpdatedAt,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return t, err
|
||
|
|
}
|
||
|
|
t.Tags = []string{}
|
||
|
|
t.AutoReplyStatus = AutoReplyNone
|
||
|
|
return t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanTicketLegacyWithMeta(row pgx.Row) (Ticket, error) {
|
||
|
|
var t Ticket
|
||
|
|
err := row.Scan(
|
||
|
|
&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Subject, &t.Category, &t.Status, &t.Priority,
|
||
|
|
&t.AssigneeAdminUserID, &t.LastMessageAt, &t.LastCustomerMessageAt, &t.LastAgentMessageAt,
|
||
|
|
&t.ResolvedAt, &t.ClosedAt, &t.CreatedAt, &t.UpdatedAt,
|
||
|
|
&t.CompanyName, &t.CreatedByEmail, &t.AssigneeEmail,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return t, err
|
||
|
|
}
|
||
|
|
t.Tags = []string{}
|
||
|
|
t.AutoReplyStatus = AutoReplyNone
|
||
|
|
return t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListForUser returns tickets created by the user within the company.
|
||
|
|
func (s *Service) ListForUser(ctx context.Context, companyID, userID uuid.UUID, status string, limit, offset int) ([]Ticket, int64, error) {
|
||
|
|
limit, offset = clampListBounds(limit, offset)
|
||
|
|
status = strings.ToLower(strings.TrimSpace(status))
|
||
|
|
args := []any{companyID, userID}
|
||
|
|
where := `company_id = $1 AND created_by_user_id = $2`
|
||
|
|
if status != "" {
|
||
|
|
if _, err := normalizeStatus(status); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
args = append(args, status)
|
||
|
|
where += fmt.Sprintf(` AND status = $%d`, len(args))
|
||
|
|
}
|
||
|
|
var total int64
|
||
|
|
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM support_tickets WHERE `+where, args...).Scan(&total); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
args = append(args, limit, offset)
|
||
|
|
q := fmt.Sprintf(`
|
||
|
|
SELECT %s FROM support_tickets t
|
||
|
|
WHERE %s
|
||
|
|
ORDER BY COALESCE(t.last_message_at, t.updated_at) DESC
|
||
|
|
LIMIT $%d OFFSET $%d`, ticketListSelectCols, where, len(args)-1, len(args))
|
||
|
|
rows, err := s.Pool.Query(ctx, q, args...)
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
q = fmt.Sprintf(`
|
||
|
|
SELECT %s FROM support_tickets t
|
||
|
|
WHERE %s
|
||
|
|
ORDER BY COALESCE(t.last_message_at, t.updated_at) DESC
|
||
|
|
LIMIT $%d OFFSET $%d`, ticketSelectColsLegacy, where, len(args)-1, len(args))
|
||
|
|
rows, err = s.Pool.Query(ctx, q, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := make([]Ticket, 0, limit)
|
||
|
|
for rows.Next() {
|
||
|
|
t, err := scanTicketLegacy(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
out = append(out, t)
|
||
|
|
}
|
||
|
|
return out, total, rows.Err()
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := make([]Ticket, 0, limit)
|
||
|
|
for rows.Next() {
|
||
|
|
t, err := scanTicketList(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
out = append(out, t)
|
||
|
|
}
|
||
|
|
return out, total, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListAdmin returns the platform support queue (all companies).
|
||
|
|
func (s *Service) ListAdmin(ctx context.Context, f ListFilter, limit, offset int) ([]Ticket, int64, error) {
|
||
|
|
limit, offset = clampListBounds(limit, offset)
|
||
|
|
args := make([]any, 0, 8)
|
||
|
|
where := `TRUE`
|
||
|
|
if f.Status != "" {
|
||
|
|
st, err := normalizeStatus(f.Status)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
args = append(args, st)
|
||
|
|
where += fmt.Sprintf(` AND t.status = $%d`, len(args))
|
||
|
|
}
|
||
|
|
if f.CompanyID != nil {
|
||
|
|
args = append(args, *f.CompanyID)
|
||
|
|
where += fmt.Sprintf(` AND t.company_id = $%d`, len(args))
|
||
|
|
}
|
||
|
|
if f.AssigneeID != nil {
|
||
|
|
args = append(args, *f.AssigneeID)
|
||
|
|
where += fmt.Sprintf(` AND t.assignee_admin_user_id = $%d`, len(args))
|
||
|
|
}
|
||
|
|
if err := applyStaffListScope(&f, &args, &where); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
ApplyQueueFlag(&f, &args, &where)
|
||
|
|
search := strings.TrimSpace(f.Search)
|
||
|
|
needSearchJoin := search != ""
|
||
|
|
if needSearchJoin {
|
||
|
|
args = append(args, "%"+search+"%")
|
||
|
|
where += fmt.Sprintf(` AND (t.subject ILIKE $%d OR COALESCE(u.email, '') ILIKE $%d OR COALESCE(c.name, '') ILIKE $%d)`, len(args), len(args), len(args))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Count without joining users/companies unless search needs them (avoids extra heap/IO).
|
||
|
|
var total int64
|
||
|
|
var countQ string
|
||
|
|
if needSearchJoin {
|
||
|
|
countQ = `
|
||
|
|
SELECT count(*) FROM support_tickets t
|
||
|
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
||
|
|
LEFT JOIN companies c ON c.id = t.company_id
|
||
|
|
WHERE ` + where
|
||
|
|
} else {
|
||
|
|
countQ = `SELECT count(*) FROM support_tickets t WHERE ` + where
|
||
|
|
}
|
||
|
|
if err := s.Pool.QueryRow(ctx, countQ, args...).Scan(&total); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
args = append(args, limit, offset)
|
||
|
|
q := fmt.Sprintf(`
|
||
|
|
SELECT %s, COALESCE(c.name, ''), COALESCE(u.email, ''), COALESCE(a.email, '')
|
||
|
|
FROM support_tickets t
|
||
|
|
LEFT JOIN companies c ON c.id = t.company_id
|
||
|
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
||
|
|
LEFT JOIN users a ON a.id = t.assignee_admin_user_id
|
||
|
|
WHERE %s
|
||
|
|
ORDER BY
|
||
|
|
CASE t.status WHEN 'open' THEN 0 WHEN 'pending' THEN 1 WHEN 'resolved' THEN 2 ELSE 3 END,
|
||
|
|
COALESCE(t.last_message_at, t.updated_at) DESC
|
||
|
|
LIMIT $%d OFFSET $%d`, ticketListSelectCols, where, len(args)-1, len(args))
|
||
|
|
rows, err := s.Pool.Query(ctx, q, args...)
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
q = fmt.Sprintf(`
|
||
|
|
SELECT %s, COALESCE(c.name, ''), COALESCE(u.email, ''), COALESCE(a.email, '')
|
||
|
|
FROM support_tickets t
|
||
|
|
LEFT JOIN companies c ON c.id = t.company_id
|
||
|
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
||
|
|
LEFT JOIN users a ON a.id = t.assignee_admin_user_id
|
||
|
|
WHERE %s
|
||
|
|
ORDER BY
|
||
|
|
CASE t.status WHEN 'open' THEN 0 WHEN 'pending' THEN 1 WHEN 'resolved' THEN 2 ELSE 3 END,
|
||
|
|
COALESCE(t.last_message_at, t.updated_at) DESC
|
||
|
|
LIMIT $%d OFFSET $%d`, ticketSelectColsLegacy, where, len(args)-1, len(args))
|
||
|
|
rows, err = s.Pool.Query(ctx, q, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := make([]Ticket, 0, limit)
|
||
|
|
for rows.Next() {
|
||
|
|
t, err := scanTicketLegacyWithMeta(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
out = append(out, t)
|
||
|
|
}
|
||
|
|
return out, total, rows.Err()
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := make([]Ticket, 0, limit)
|
||
|
|
for rows.Next() {
|
||
|
|
t, err := scanTicketListWithMeta(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
out = append(out, t)
|
||
|
|
}
|
||
|
|
return out, total, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
const (
|
||
|
|
defaultTicketPageLimit = 50
|
||
|
|
maxTicketPageLimit = 200
|
||
|
|
)
|
||
|
|
|
||
|
|
func clampListBounds(limit, offset int) (int, int) {
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = defaultTicketPageLimit
|
||
|
|
}
|
||
|
|
if limit > maxTicketPageLimit {
|
||
|
|
limit = maxTicketPageLimit
|
||
|
|
}
|
||
|
|
if offset < 0 {
|
||
|
|
offset = 0
|
||
|
|
}
|
||
|
|
return limit, offset
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetForUser loads a ticket owned by the user in the company (no internal notes).
|
||
|
|
func (s *Service) GetForUser(ctx context.Context, companyID, userID, ticketID uuid.UUID) (Ticket, error) {
|
||
|
|
row := s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT `+ticketDetailSelectCols+`
|
||
|
|
FROM support_tickets t
|
||
|
|
WHERE t.id = $1 AND t.company_id = $2 AND t.created_by_user_id = $3`,
|
||
|
|
ticketID, companyID, userID)
|
||
|
|
t, err := scanTicketDetail(row)
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
row = s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT `+ticketSelectColsLegacy+`
|
||
|
|
FROM support_tickets t
|
||
|
|
WHERE t.id = $1 AND t.company_id = $2 AND t.created_by_user_id = $3`,
|
||
|
|
ticketID, companyID, userID)
|
||
|
|
t, err = scanTicketLegacy(row)
|
||
|
|
}
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Ticket{}, ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
// Customer GET: omit staff-only snapshot signals / match metadata.
|
||
|
|
t.CustomerContext = redactCustomerContextForUser(t.CustomerContext)
|
||
|
|
t.AutoReplyMeta = nil
|
||
|
|
msgs, err := s.listMessages(ctx, ticketID, false)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
t.Messages = msgs
|
||
|
|
if err := s.attachCSAT(ctx, &t, true); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
return t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetAdmin loads any ticket with all messages including internal notes + activity.
|
||
|
|
func (s *Service) GetAdmin(ctx context.Context, ticketID uuid.UUID) (Ticket, error) {
|
||
|
|
row := s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT `+ticketDetailSelectCols+`, COALESCE(c.name, ''), COALESCE(u.email, ''), COALESCE(a.email, '')
|
||
|
|
FROM support_tickets t
|
||
|
|
LEFT JOIN companies c ON c.id = t.company_id
|
||
|
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
||
|
|
LEFT JOIN users a ON a.id = t.assignee_admin_user_id
|
||
|
|
WHERE t.id = $1`, ticketID)
|
||
|
|
t, err := scanTicketDetailWithMeta(row)
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
row = s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT `+ticketSelectColsLegacy+`, COALESCE(c.name, ''), COALESCE(u.email, ''), COALESCE(a.email, '')
|
||
|
|
FROM support_tickets t
|
||
|
|
LEFT JOIN companies c ON c.id = t.company_id
|
||
|
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
||
|
|
LEFT JOIN users a ON a.id = t.assignee_admin_user_id
|
||
|
|
WHERE t.id = $1`, ticketID)
|
||
|
|
t, err = scanTicketLegacyWithMeta(row)
|
||
|
|
}
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Ticket{}, ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
msgs, err := s.listMessages(ctx, ticketID, true)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
t.Messages = msgs
|
||
|
|
activity, err := s.listActivity(ctx, ticketID)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
t.Activity = activity
|
||
|
|
if err := s.attachCSAT(ctx, &t, false); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
return t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) listMessages(ctx context.Context, ticketID uuid.UUID, includeInternal bool) ([]Message, error) {
|
||
|
|
q := `
|
||
|
|
SELECT id, ticket_id, author_user_id, author_role, body, is_internal_note, created_at,
|
||
|
|
COALESCE(is_auto_reply, false), COALESCE(auto_source, ''), auto_confidence,
|
||
|
|
COALESCE(auto_ref_type, ''), auto_ref_id
|
||
|
|
FROM support_messages
|
||
|
|
WHERE ticket_id = $1`
|
||
|
|
if !includeInternal {
|
||
|
|
q += ` AND is_internal_note = false`
|
||
|
|
}
|
||
|
|
q += ` ORDER BY created_at ASC`
|
||
|
|
rows, err := s.Pool.Query(ctx, q, ticketID)
|
||
|
|
if err != nil {
|
||
|
|
// Pre-031 schema: fall back without auto columns.
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
return s.listMessagesLegacy(ctx, ticketID, includeInternal)
|
||
|
|
}
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := make([]Message, 0)
|
||
|
|
for rows.Next() {
|
||
|
|
var m Message
|
||
|
|
var conf *float32
|
||
|
|
if err := rows.Scan(
|
||
|
|
&m.ID, &m.TicketID, &m.AuthorUserID, &m.AuthorRole, &m.Body, &m.IsInternalNote, &m.CreatedAt,
|
||
|
|
&m.IsAutoReply, &m.AutoSource, &conf, &m.AutoRefType, &m.AutoRefID,
|
||
|
|
); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
m.AutoConfidence = conf
|
||
|
|
out = append(out, m)
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) listMessagesLegacy(ctx context.Context, ticketID uuid.UUID, includeInternal bool) ([]Message, error) {
|
||
|
|
q := `
|
||
|
|
SELECT id, ticket_id, author_user_id, author_role, body, is_internal_note, created_at
|
||
|
|
FROM support_messages
|
||
|
|
WHERE ticket_id = $1`
|
||
|
|
if !includeInternal {
|
||
|
|
q += ` AND is_internal_note = false`
|
||
|
|
}
|
||
|
|
q += ` ORDER BY created_at ASC`
|
||
|
|
rows, err := s.Pool.Query(ctx, q, ticketID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := make([]Message, 0)
|
||
|
|
for rows.Next() {
|
||
|
|
var m Message
|
||
|
|
if err := rows.Scan(&m.ID, &m.TicketID, &m.AuthorUserID, &m.AuthorRole, &m.Body, &m.IsInternalNote, &m.CreatedAt); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out = append(out, m)
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func redactCustomerContextForUser(raw json.RawMessage) json.RawMessage {
|
||
|
|
if len(raw) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
var m map[string]any
|
||
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
delete(m, "signals")
|
||
|
|
delete(m, "user_email")
|
||
|
|
b, err := json.Marshal(m)
|
||
|
|
if err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return b
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create opens a ticket with the first customer message (status=open).
|
||
|
|
// Do not call TryAutoReplyLLM or any Completer here — staff replies are human-only
|
||
|
|
// until product opts into AI draft assist via the safe stub.
|
||
|
|
func (s *Service) Create(ctx context.Context, companyID, userID uuid.UUID, in CreateInput) (Ticket, error) {
|
||
|
|
subject, err := normalizeSubject(in.Subject)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
category, err := s.normalizeCategoryActive(ctx, in.Category)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
priority, err := normalizePriority(in.Priority)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
body, err := normalizeBody(in.Body)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
tags, err := normalizeTags(in.Tags)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
relatedProductID, err := normalizeRelatedProductID(in.RelatedProductID)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
relatedSKU, err := normalizeRelatedSKU(in.RelatedSKU)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
if err := s.ensureRelatedProductInCompany(ctx, companyID, relatedProductID); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
customerCtx := s.captureCustomerContext(ctx, companyID, userID, relatedProductID, relatedSKU)
|
||
|
|
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
|
||
|
|
now := time.Now().UTC()
|
||
|
|
var ticketID uuid.UUID
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO support_tickets (
|
||
|
|
company_id, created_by_user_id, subject, category, priority, status,
|
||
|
|
tags, related_product_id, related_sku, customer_context,
|
||
|
|
auto_reply_disabled, auto_reply_status, auto_reply_meta,
|
||
|
|
last_message_at, last_customer_message_at, created_at, updated_at
|
||
|
|
) VALUES (
|
||
|
|
$1,$2,$3,$4,$5,'open',
|
||
|
|
$6,$7,NULLIF($8,''),$9,
|
||
|
|
false,'none','{}'::jsonb,
|
||
|
|
$10,$10,$10,$10
|
||
|
|
)
|
||
|
|
RETURNING id`,
|
||
|
|
companyID, userID, subject, category, priority,
|
||
|
|
tags, relatedProductID, relatedSKU, customerCtx, now,
|
||
|
|
).Scan(&ticketID)
|
||
|
|
if err != nil {
|
||
|
|
// Pre-031 fallback insert.
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
_ = tx.Rollback(ctx)
|
||
|
|
return s.createLegacy(ctx, companyID, userID, subject, category, priority, body, now)
|
||
|
|
}
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
var msgID uuid.UUID
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO support_messages (ticket_id, company_id, author_user_id, author_role, body, is_internal_note, created_at)
|
||
|
|
VALUES ($1,$2,$3,'user',$4,false,$5)
|
||
|
|
RETURNING id`,
|
||
|
|
ticketID, companyID, userID, body, now,
|
||
|
|
).Scan(&msgID)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
_ = insertActivity(ctx, tx, ticketID, companyID, ActivityCreated, "user", &userID, &msgID, json.RawMessage(`{"source":"create"}`))
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
return s.GetForUser(ctx, companyID, userID, ticketID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) createLegacy(
|
||
|
|
ctx context.Context, companyID, userID uuid.UUID,
|
||
|
|
subject, category, priority, body string, now time.Time,
|
||
|
|
) (Ticket, error) {
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
var ticketID uuid.UUID
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO support_tickets (
|
||
|
|
company_id, created_by_user_id, subject, category, priority, status,
|
||
|
|
last_message_at, last_customer_message_at, created_at, updated_at
|
||
|
|
) VALUES ($1,$2,$3,$4,$5,'open',$6,$6,$6,$6)
|
||
|
|
RETURNING id`,
|
||
|
|
companyID, userID, subject, category, priority, now,
|
||
|
|
).Scan(&ticketID)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
INSERT INTO support_messages (ticket_id, company_id, author_user_id, author_role, body, is_internal_note, created_at)
|
||
|
|
VALUES ($1,$2,$3,'user',$4,false,$5)`,
|
||
|
|
ticketID, companyID, userID, body, now,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
return s.GetForUser(ctx, companyID, userID, ticketID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ReplyAsUser appends a customer message and reopens the ticket when needed.
|
||
|
|
func (s *Service) ReplyAsUser(ctx context.Context, companyID, userID, ticketID uuid.UUID, in ReplyInput) (Ticket, error) {
|
||
|
|
body, err := normalizeBody(in.Body)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
|
||
|
|
var t Ticket
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
SELECT id, company_id, created_by_user_id, status, assignee_admin_user_id
|
||
|
|
FROM support_tickets
|
||
|
|
WHERE id = $1 AND company_id = $2 AND created_by_user_id = $3
|
||
|
|
FOR UPDATE`, ticketID, companyID, userID,
|
||
|
|
).Scan(&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Status, &t.AssigneeAdminUserID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Ticket{}, ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
if t.Status == "closed" {
|
||
|
|
return Ticket{}, ErrTicketClosed
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Now().UTC()
|
||
|
|
var msgID uuid.UUID
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO support_messages (ticket_id, company_id, author_user_id, author_role, body, is_internal_note, created_at)
|
||
|
|
VALUES ($1,$2,$3,'user',$4,false,$5)
|
||
|
|
RETURNING id`,
|
||
|
|
ticketID, companyID, userID, body, now,
|
||
|
|
).Scan(&msgID)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets SET
|
||
|
|
status = 'open',
|
||
|
|
resolved_at = NULL,
|
||
|
|
last_message_at = $2,
|
||
|
|
last_customer_message_at = $2,
|
||
|
|
updated_at = $2,
|
||
|
|
auto_reply_disabled = CASE
|
||
|
|
WHEN COALESCE(auto_reply_status, 'none') IN ('matched','ai_sent') THEN true
|
||
|
|
ELSE auto_reply_disabled
|
||
|
|
END,
|
||
|
|
auto_reply_status = CASE
|
||
|
|
WHEN COALESCE(auto_reply_status, 'none') IN ('matched','ai_sent') THEN 'handed_off'
|
||
|
|
ELSE auto_reply_status
|
||
|
|
END
|
||
|
|
WHERE id = $1`, ticketID, now)
|
||
|
|
if err != nil {
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets SET
|
||
|
|
status = 'open',
|
||
|
|
resolved_at = NULL,
|
||
|
|
last_message_at = $2,
|
||
|
|
last_customer_message_at = $2,
|
||
|
|
updated_at = $2
|
||
|
|
WHERE id = $1`, ticketID, now)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
_ = insertActivity(ctx, tx, ticketID, t.CompanyID, ActivityCustomerMessage, "user", &userID, &msgID, nil)
|
||
|
|
}
|
||
|
|
if t.AssigneeAdminUserID != nil {
|
||
|
|
if err := insertNotification(ctx, tx, *t.AssigneeAdminUserID, ticketID, &msgID, "user_reply"); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
return s.GetForUser(ctx, companyID, userID, ticketID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ReplyAsAgent appends a staff message (or internal note) and notifies the ticket owner on public replies.
|
||
|
|
// Human-authored only — do not invoke TryAutoReplyLLM or ResolveCompleterForRole(RoleSupport) here.
|
||
|
|
func (s *Service) ReplyAsAgent(ctx context.Context, agentUserID, ticketID uuid.UUID, in ReplyInput) (Ticket, error) {
|
||
|
|
body, err := normalizeBody(in.Body)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
var newStatus string
|
||
|
|
if in.Status != nil {
|
||
|
|
newStatus, err = normalizeStatus(*in.Status)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
|
||
|
|
var t Ticket
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
SELECT id, company_id, created_by_user_id, status, assignee_admin_user_id
|
||
|
|
FROM support_tickets WHERE id = $1 FOR UPDATE`, ticketID,
|
||
|
|
).Scan(&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Status, &t.AssigneeAdminUserID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Ticket{}, ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
if t.Status == "closed" && !in.IsInternalNote {
|
||
|
|
return Ticket{}, ErrTicketClosed
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Now().UTC()
|
||
|
|
var msgID uuid.UUID
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO support_messages (ticket_id, company_id, author_user_id, author_role, body, is_internal_note, created_at)
|
||
|
|
VALUES ($1,$2,$3,'agent',$4,$5,$6)
|
||
|
|
RETURNING id`,
|
||
|
|
ticketID, t.CompanyID, agentUserID, body, in.IsInternalNote, now,
|
||
|
|
).Scan(&msgID)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
status := t.Status
|
||
|
|
statusChanged := false
|
||
|
|
if !in.IsInternalNote {
|
||
|
|
if newStatus != "" {
|
||
|
|
status = newStatus
|
||
|
|
} else if status == "open" || status == "resolved" {
|
||
|
|
// Default: waiting on customer after a public agent reply.
|
||
|
|
status = "pending"
|
||
|
|
}
|
||
|
|
statusChanged = status != t.Status
|
||
|
|
} else if newStatus != "" {
|
||
|
|
status = newStatus
|
||
|
|
statusChanged = status != t.Status
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets SET
|
||
|
|
status = $2,
|
||
|
|
assignee_admin_user_id = COALESCE(assignee_admin_user_id, $3),
|
||
|
|
last_message_at = CASE WHEN $4 THEN $5 ELSE last_message_at END,
|
||
|
|
last_agent_message_at = CASE WHEN $4 THEN $5 ELSE last_agent_message_at END,
|
||
|
|
resolved_at = CASE
|
||
|
|
WHEN $2 IN ('open','pending') THEN NULL
|
||
|
|
WHEN $2 IN ('resolved','closed') THEN COALESCE(resolved_at, $5)
|
||
|
|
ELSE resolved_at
|
||
|
|
END,
|
||
|
|
resolved_by_user_id = CASE
|
||
|
|
WHEN $2 = 'resolved' AND $6::boolean THEN $3
|
||
|
|
WHEN $2 IN ('open','pending') THEN NULL
|
||
|
|
ELSE resolved_by_user_id
|
||
|
|
END,
|
||
|
|
closed_at = CASE
|
||
|
|
WHEN $2 = 'closed' THEN COALESCE(closed_at, $5)
|
||
|
|
WHEN $2 IN ('open','pending','resolved') THEN NULL
|
||
|
|
ELSE closed_at
|
||
|
|
END,
|
||
|
|
auto_reply_disabled = CASE WHEN $4 THEN true ELSE auto_reply_disabled END,
|
||
|
|
updated_at = $5
|
||
|
|
WHERE id = $1`,
|
||
|
|
ticketID, status, agentUserID, !in.IsInternalNote, now, statusChanged && status == "resolved",
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets SET
|
||
|
|
status = $2,
|
||
|
|
assignee_admin_user_id = COALESCE(assignee_admin_user_id, $3),
|
||
|
|
last_message_at = CASE WHEN $4 THEN $5 ELSE last_message_at END,
|
||
|
|
last_agent_message_at = CASE WHEN $4 THEN $5 ELSE last_agent_message_at END,
|
||
|
|
resolved_at = CASE
|
||
|
|
WHEN $2 IN ('open','pending') THEN NULL
|
||
|
|
WHEN $2 IN ('resolved','closed') THEN COALESCE(resolved_at, $5)
|
||
|
|
ELSE resolved_at
|
||
|
|
END,
|
||
|
|
resolved_by_user_id = CASE
|
||
|
|
WHEN $2 = 'resolved' AND $6::boolean THEN $3
|
||
|
|
WHEN $2 IN ('open','pending') THEN NULL
|
||
|
|
ELSE resolved_by_user_id
|
||
|
|
END,
|
||
|
|
closed_at = CASE
|
||
|
|
WHEN $2 = 'closed' THEN COALESCE(closed_at, $5)
|
||
|
|
WHEN $2 IN ('open','pending','resolved') THEN NULL
|
||
|
|
ELSE closed_at
|
||
|
|
END,
|
||
|
|
updated_at = $5
|
||
|
|
WHERE id = $1`,
|
||
|
|
ticketID, status, agentUserID, !in.IsInternalNote, now, statusChanged && status == "resolved",
|
||
|
|
)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
kind := ActivityAgentMessage
|
||
|
|
if in.IsInternalNote {
|
||
|
|
kind = ActivityNote
|
||
|
|
}
|
||
|
|
_ = insertActivity(ctx, tx, ticketID, t.CompanyID, kind, "agent", &agentUserID, &msgID, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
if !in.IsInternalNote {
|
||
|
|
if err := insertNotification(ctx, tx, t.CreatedByUserID, ticketID, &msgID, "agent_reply"); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if statusChanged {
|
||
|
|
if err := insertNotification(ctx, tx, t.CreatedByUserID, ticketID, &msgID, "status_changed"); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
return s.GetAdmin(ctx, ticketID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// UpdateAdmin patches status / priority / assignee / detail fields without requiring a message.
|
||
|
|
func (s *Service) UpdateAdmin(ctx context.Context, ticketID uuid.UUID, actorUserID uuid.UUID, in AdminUpdateInput) (Ticket, error) {
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
|
||
|
|
var t Ticket
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
SELECT id, company_id, created_by_user_id, status, priority, category,
|
||
|
|
COALESCE(auto_reply_disabled, false), assignee_admin_user_id
|
||
|
|
FROM support_tickets WHERE id = $1 FOR UPDATE`, ticketID,
|
||
|
|
).Scan(&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Status, &t.Priority, &t.Category, &t.AutoReplyDisabled, &t.AssigneeAdminUserID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Ticket{}, ErrNotFound
|
||
|
|
}
|
||
|
|
if isUndefinedColumn(err) {
|
||
|
|
_ = tx.Rollback(ctx)
|
||
|
|
return s.updateAdminLegacy(ctx, ticketID, actorUserID, in)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
status := t.Status
|
||
|
|
statusChanged := false
|
||
|
|
if in.Status != nil {
|
||
|
|
status, err = normalizeStatus(*in.Status)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
statusChanged = status != t.Status
|
||
|
|
}
|
||
|
|
priority := t.Priority
|
||
|
|
if in.Priority != nil {
|
||
|
|
priority, err = normalizePriority(*in.Priority)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
category := t.Category
|
||
|
|
if in.Category != nil {
|
||
|
|
category, err = s.normalizeCategoryActive(ctx, *in.Category)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var tags any = []string{}
|
||
|
|
if in.SetTags {
|
||
|
|
normalized, err := normalizeTags(in.Tags)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
tags = normalized
|
||
|
|
}
|
||
|
|
|
||
|
|
relatedSKUSet := false
|
||
|
|
var relatedSKU string
|
||
|
|
if in.RelatedSKU != nil {
|
||
|
|
relatedSKU, err = normalizeRelatedSKU(*in.RelatedSKU)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
relatedSKUSet = true
|
||
|
|
}
|
||
|
|
|
||
|
|
var relatedProduct any
|
||
|
|
relatedProductSet := false
|
||
|
|
switch {
|
||
|
|
case in.ClearRelatedProduct:
|
||
|
|
relatedProduct = nil
|
||
|
|
relatedProductSet = true
|
||
|
|
case in.RelatedProductID != nil:
|
||
|
|
pid, err := normalizeRelatedProductID(in.RelatedProductID)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
if err := s.ensureRelatedProductInCompany(ctx, t.CompanyID, pid); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
relatedProduct = pid
|
||
|
|
relatedProductSet = true
|
||
|
|
}
|
||
|
|
|
||
|
|
autoDisabled := t.AutoReplyDisabled
|
||
|
|
autoDisabledChanged := false
|
||
|
|
if in.AutoReplyDisabled != nil {
|
||
|
|
autoDisabled = *in.AutoReplyDisabled
|
||
|
|
autoDisabledChanged = autoDisabled != t.AutoReplyDisabled
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Now().UTC()
|
||
|
|
var assignee any
|
||
|
|
switch {
|
||
|
|
case in.ClearAssignee:
|
||
|
|
assignee = nil
|
||
|
|
case in.AssigneeAdminUserID != nil:
|
||
|
|
if *in.AssigneeAdminUserID == uuid.Nil {
|
||
|
|
return Ticket{}, ErrInvalidAssignee
|
||
|
|
}
|
||
|
|
assignee = *in.AssigneeAdminUserID
|
||
|
|
default:
|
||
|
|
assignee = t.AssigneeAdminUserID
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets SET
|
||
|
|
status = $2,
|
||
|
|
priority = $3,
|
||
|
|
category = $4,
|
||
|
|
assignee_admin_user_id = $5,
|
||
|
|
tags = CASE WHEN $6::boolean THEN $7::text[] ELSE tags END,
|
||
|
|
related_product_id = CASE WHEN $8::boolean THEN $9::uuid ELSE related_product_id END,
|
||
|
|
related_sku = CASE WHEN $10::boolean THEN NULLIF($11,'') ELSE related_sku END,
|
||
|
|
auto_reply_disabled = $12,
|
||
|
|
resolved_at = CASE
|
||
|
|
WHEN $2 IN ('open','pending') THEN NULL
|
||
|
|
WHEN $2 = 'resolved' THEN COALESCE(resolved_at, $13)
|
||
|
|
WHEN $2 = 'closed' THEN COALESCE(resolved_at, $13)
|
||
|
|
ELSE resolved_at
|
||
|
|
END,
|
||
|
|
resolved_by_user_id = CASE
|
||
|
|
WHEN $2 = 'resolved' AND $14::boolean THEN $15
|
||
|
|
WHEN $2 IN ('open','pending') THEN NULL
|
||
|
|
ELSE resolved_by_user_id
|
||
|
|
END,
|
||
|
|
closed_at = CASE
|
||
|
|
WHEN $2 = 'closed' THEN COALESCE(closed_at, $13)
|
||
|
|
WHEN $2 IN ('open','pending','resolved') THEN NULL
|
||
|
|
ELSE closed_at
|
||
|
|
END,
|
||
|
|
updated_at = $13
|
||
|
|
WHERE id = $1`,
|
||
|
|
ticketID, status, priority, category, assignee,
|
||
|
|
in.SetTags, tags,
|
||
|
|
relatedProductSet, relatedProduct,
|
||
|
|
relatedSKUSet, relatedSKU,
|
||
|
|
autoDisabled, now,
|
||
|
|
statusChanged && status == "resolved", actorUserID,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
if statusChanged {
|
||
|
|
if err := insertNotification(ctx, tx, t.CreatedByUserID, ticketID, nil, "status_changed"); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
_ = insertActivity(ctx, tx, ticketID, t.CompanyID, ActivityStatusChanged, "agent", &actorUserID, nil,
|
||
|
|
json.RawMessage(fmt.Sprintf(`{"from":%q,"to":%q}`, t.Status, status)))
|
||
|
|
}
|
||
|
|
if autoDisabledChanged {
|
||
|
|
kind := ActivityAutoEnabled
|
||
|
|
if autoDisabled {
|
||
|
|
kind = ActivityAutoDisabled
|
||
|
|
}
|
||
|
|
_ = insertActivity(ctx, tx, ticketID, t.CompanyID, kind, "agent", &actorUserID, nil, nil)
|
||
|
|
}
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
return s.GetAdmin(ctx, ticketID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) updateAdminLegacy(ctx context.Context, ticketID uuid.UUID, actorUserID uuid.UUID, in AdminUpdateInput) (Ticket, error) {
|
||
|
|
// Narrow patch for pre-031 DBs (status/priority/assignee only).
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
|
||
|
|
var t Ticket
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
SELECT id, created_by_user_id, status, priority, assignee_admin_user_id
|
||
|
|
FROM support_tickets WHERE id = $1 FOR UPDATE`, ticketID,
|
||
|
|
).Scan(&t.ID, &t.CreatedByUserID, &t.Status, &t.Priority, &t.AssigneeAdminUserID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Ticket{}, ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
status := t.Status
|
||
|
|
statusChanged := false
|
||
|
|
if in.Status != nil {
|
||
|
|
status, err = normalizeStatus(*in.Status)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
statusChanged = status != t.Status
|
||
|
|
}
|
||
|
|
priority := t.Priority
|
||
|
|
if in.Priority != nil {
|
||
|
|
priority, err = normalizePriority(*in.Priority)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
now := time.Now().UTC()
|
||
|
|
var assignee any
|
||
|
|
switch {
|
||
|
|
case in.ClearAssignee:
|
||
|
|
assignee = nil
|
||
|
|
case in.AssigneeAdminUserID != nil:
|
||
|
|
if *in.AssigneeAdminUserID == uuid.Nil {
|
||
|
|
return Ticket{}, ErrInvalidAssignee
|
||
|
|
}
|
||
|
|
assignee = *in.AssigneeAdminUserID
|
||
|
|
default:
|
||
|
|
assignee = t.AssigneeAdminUserID
|
||
|
|
}
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets SET
|
||
|
|
status = $2,
|
||
|
|
priority = $3,
|
||
|
|
assignee_admin_user_id = $4,
|
||
|
|
resolved_at = CASE
|
||
|
|
WHEN $2 IN ('open','pending') THEN NULL
|
||
|
|
WHEN $2 = 'resolved' THEN COALESCE(resolved_at, $5)
|
||
|
|
WHEN $2 = 'closed' THEN COALESCE(resolved_at, $5)
|
||
|
|
ELSE resolved_at
|
||
|
|
END,
|
||
|
|
resolved_by_user_id = CASE
|
||
|
|
WHEN $2 = 'resolved' AND $7::boolean THEN $6
|
||
|
|
WHEN $2 IN ('open','pending') THEN NULL
|
||
|
|
ELSE resolved_by_user_id
|
||
|
|
END,
|
||
|
|
closed_at = CASE
|
||
|
|
WHEN $2 = 'closed' THEN COALESCE(closed_at, $5)
|
||
|
|
WHEN $2 IN ('open','pending','resolved') THEN NULL
|
||
|
|
ELSE closed_at
|
||
|
|
END,
|
||
|
|
updated_at = $5
|
||
|
|
WHERE id = $1`,
|
||
|
|
ticketID, status, priority, assignee, now, actorUserID, statusChanged && status == "resolved",
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
if statusChanged {
|
||
|
|
if err := insertNotification(ctx, tx, t.CreatedByUserID, ticketID, nil, "status_changed"); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return Ticket{}, err
|
||
|
|
}
|
||
|
|
return s.GetAdmin(ctx, ticketID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func insertNotification(ctx context.Context, tx pgx.Tx, userID, ticketID uuid.UUID, messageID *uuid.UUID, kind string) error {
|
||
|
|
_, err := tx.Exec(ctx, `
|
||
|
|
INSERT INTO support_notifications (user_id, ticket_id, message_id, kind)
|
||
|
|
VALUES ($1,$2,$3,$4)`, userID, ticketID, messageID, kind)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// IsMissingRelation reports whether err is an undefined-table / missing-migration error.
|
||
|
|
func IsMissingRelation(err error) bool {
|
||
|
|
var pgErr *pgconn.PgError
|
||
|
|
if errors.As(err, &pgErr) {
|
||
|
|
return pgErr.Code == "42P01"
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|