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 }