Files
descrybe/apps/api/internal/support/tickets.go
T

1114 lines
33 KiB
Go
Raw Normal View History

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
}