package support import ( "context" "encoding/json" "strings" "github.com/google/uuid" ) // Queue flag filters for staff inbox (agent 8). const ( FlagNeedsHuman = "needs_human" FlagAIDraft = "ai_draft" ) // ApproveAIDraftInput is the staff approve payload for draft_only AI replies. type ApproveAIDraftInput struct { Body string `json:"body"` Status *string `json:"status"` } // ApplyQueueFlag appends WHERE clauses for staff auto-assist filters. // Unknown flags are ignored (fail open on filter only — never broaden visibility). func ApplyQueueFlag(f *ListFilter, args *[]any, where *string) { if f == nil || args == nil || where == nil { return } flag := strings.ToLower(strings.TrimSpace(f.Flag)) switch flag { case FlagAIDraft: *where += ` AND COALESCE(t.auto_reply_status, 'none') = 'ai_draft'` case FlagNeedsHuman: *where += ` AND COALESCE(t.auto_reply_status, 'none') IN ('handed_off','failed','skipped')` default: return } } func findAIDraftMessage(msgs []Message, preferredID *uuid.UUID) *Message { if preferredID != nil { for i := range msgs { if msgs[i].ID == *preferredID { return &msgs[i] } } } for i := len(msgs) - 1; i >= 0; i-- { m := &msgs[i] if !m.IsInternalNote { continue } src := strings.ToLower(strings.TrimSpace(m.AutoSource)) if m.IsAutoReply && (src == "ai" || src == "") { return m } if src == "ai" { return m } } for i := len(msgs) - 1; i >= 0; i-- { m := &msgs[i] if m.IsInternalNote && m.IsAutoReply { return m } } return nil } // ApproveAIDraft posts an edited (or original) AI draft as a public staff reply. // Enforces the same ticket visibility as other desk mutations via the caller. func (s *Service) ApproveAIDraft(ctx context.Context, agentUserID, ticketID uuid.UUID, in ApproveAIDraftInput) (Ticket, error) { t, err := s.GetAdmin(ctx, ticketID) if err != nil { return Ticket{}, err } if strings.ToLower(strings.TrimSpace(t.AutoReplyStatus)) != AutoReplyAIDraft { return Ticket{}, ErrNoAIDraft } body := strings.TrimSpace(in.Body) if body == "" { draft := findAIDraftMessage(t.Messages, t.AutoReplyMessageID) if draft == nil { return Ticket{}, ErrNoAIDraft } body = draft.Body } out, err := s.ReplyAsAgent(ctx, agentUserID, ticketID, ReplyInput{ Body: body, Status: in.Status, }) if err != nil { return Ticket{}, err } _ = out meta, _ := json.Marshal(map[string]any{ "action": "approve_ai_draft", "by": agentUserID.String(), }) _ = s.RecordAutoReplyOutcome(ctx, ticketID, AutoReplyAISent, true, nil, meta, ActivityAISent) return s.GetAdmin(ctx, ticketID) } // DiscardAIDraft marks the ticket as handed off, disables further auto, and keeps the internal note. func (s *Service) DiscardAIDraft(ctx context.Context, agentUserID, ticketID uuid.UUID) (Ticket, error) { t, err := s.GetAdmin(ctx, ticketID) if err != nil { return Ticket{}, err } status := strings.ToLower(strings.TrimSpace(t.AutoReplyStatus)) draft := findAIDraftMessage(t.Messages, t.AutoReplyMessageID) if status != AutoReplyAIDraft && draft == nil { return Ticket{}, ErrNoAIDraft } disabled := true if _, err := s.UpdateAdmin(ctx, ticketID, agentUserID, AdminUpdateInput{ AutoReplyDisabled: &disabled, }); err != nil { return Ticket{}, err } meta, _ := json.Marshal(map[string]any{ "action": "discard_ai_draft", "by": agentUserID.String(), }) if err := s.RecordAutoReplyOutcome(ctx, ticketID, AutoReplyHandedOff, true, t.AutoReplyMessageID, meta, ActivityHandedOff); err != nil { return Ticket{}, err } return s.GetAdmin(ctx, ticketID) }