136 lines
3.5 KiB
Go
136 lines
3.5 KiB
Go
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)
|
||
|
|
}
|