package support import ( "context" "errors" "strings" "time" "unicode/utf8" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // AutoReplyTimeout is the hard deadline for one LLM auto-reply attempt. const AutoReplyTimeout = 25 * time.Second // AutoClaim is a successful idempotency claim for posting one auto reply. type AutoClaim struct { TicketID uuid.UUID CompanyID uuid.UUID } // ClaimAutoReplyAttempt marks the ticket as in-progress for auto-reply if no public // auto message exists yet and auto is not disabled. Concurrent claims fail with // ErrAutoReplyAlreadyPosted. func (s *Service) ClaimAutoReplyAttempt(ctx context.Context, ticketID uuid.UUID) (AutoClaim, error) { if s == nil || s.Pool == nil { return AutoClaim{}, ErrAIAutoReplyDisabled } tx, err := s.Pool.Begin(ctx) if err != nil { return AutoClaim{}, err } defer func() { _ = tx.Rollback(ctx) }() var ( companyID uuid.UUID disabled bool status string msgID *uuid.UUID tStatus string ) err = tx.QueryRow(ctx, ` SELECT company_id, COALESCE(auto_reply_disabled, false), COALESCE(auto_reply_status, 'none'), auto_reply_message_id, status FROM support_tickets WHERE id = $1 FOR UPDATE`, ticketID, ).Scan(&companyID, &disabled, &status, &msgID, &tStatus) if errors.Is(err, pgx.ErrNoRows) { return AutoClaim{}, ErrNotFound } if err != nil { return AutoClaim{}, err } if disabled { return AutoClaim{}, ErrAutoReplyDisabled } if tStatus == "closed" || tStatus == "resolved" { return AutoClaim{}, ErrTicketClosed } if msgID != nil || status == "matched" || status == "ai_sent" || status == "ai_draft" { return AutoClaim{}, ErrAutoReplyAlreadyPosted } var existing uuid.UUID err = tx.QueryRow(ctx, ` SELECT id FROM support_messages WHERE ticket_id = $1 AND company_id = $2 AND is_auto_reply = true AND is_internal_note = false LIMIT 1`, ticketID, companyID, ).Scan(&existing) if err == nil { return AutoClaim{}, ErrAutoReplyAlreadyPosted } if err != nil && !errors.Is(err, pgx.ErrNoRows) { return AutoClaim{}, err } now := time.Now().UTC() tag, err := tx.Exec(ctx, ` UPDATE support_tickets SET auto_reply_attempted_at = $2, updated_at = $2 WHERE id = $1 AND company_id = $3 AND auto_reply_message_id IS NULL AND COALESCE(auto_reply_disabled, false) = false`, ticketID, now, companyID, ) if err != nil { return AutoClaim{}, err } if tag.RowsAffected() == 0 { return AutoClaim{}, ErrAutoReplyAlreadyPosted } if err := tx.Commit(ctx); err != nil { return AutoClaim{}, err } return AutoClaim{TicketID: ticketID, CompanyID: companyID}, nil } // InsertAutoSystemMessage posts a labeled system auto-reply scoped to claim.CompanyID. // Unique partial index prevents double public auto posts under races. func (s *Service) InsertAutoSystemMessage( ctx context.Context, claim AutoClaim, body, source string, confidence float64, refType string, refID *uuid.UUID, internal bool, ) (uuid.UUID, error) { if s == nil || s.Pool == nil { return uuid.Nil, ErrAIAutoReplyDisabled } body = sanitizeAutoBody(body) if body == "" { return uuid.Nil, ErrBodyRequired } if source != "kb" && source != "template" && source != "ai" { source = "ai" } tx, err := s.Pool.Begin(ctx) if err != nil { return uuid.Nil, err } defer func() { _ = tx.Rollback(ctx) }() var companyID uuid.UUID var disabled bool var msgID *uuid.UUID err = tx.QueryRow(ctx, ` SELECT company_id, COALESCE(auto_reply_disabled, false), auto_reply_message_id FROM support_tickets WHERE id = $1 FOR UPDATE`, claim.TicketID, ).Scan(&companyID, &disabled, &msgID) if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, ErrNotFound } if err != nil { return uuid.Nil, err } if companyID != claim.CompanyID { return uuid.Nil, ErrForbidden } if disabled { return uuid.Nil, ErrAutoReplyDisabled } if msgID != nil && !internal { return uuid.Nil, ErrAutoReplyAlreadyPosted } now := time.Now().UTC() var refTypeArg any if strings.TrimSpace(refType) == "" { refTypeArg = nil } else { refTypeArg = strings.TrimSpace(refType) } var newID 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, is_auto_reply, auto_source, auto_confidence, auto_ref_type, auto_ref_id ) VALUES ($1,$2,NULL,'system',$3,$4,$5,true,$6,$7,$8,$9) RETURNING id`, claim.TicketID, claim.CompanyID, body, internal, now, source, confidence, refTypeArg, refID, ).Scan(&newID) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return uuid.Nil, ErrAutoReplyAlreadyPosted } return uuid.Nil, err } status := "ai_draft" if !internal { status = "ai_sent" if source == "kb" || source == "template" { status = "matched" } _, err = tx.Exec(ctx, ` UPDATE support_tickets SET auto_reply_status = $2, auto_reply_message_id = $3, status = CASE WHEN status = 'open' THEN 'pending' ELSE status END, last_message_at = $4, last_agent_message_at = $4, updated_at = $4 WHERE id = $1 AND company_id = $5`, claim.TicketID, status, newID, now, claim.CompanyID, ) } else { _, err = tx.Exec(ctx, ` UPDATE support_tickets SET auto_reply_status = $2, updated_at = $3 WHERE id = $1 AND company_id = $4`, claim.TicketID, status, now, claim.CompanyID, ) } if err != nil { return uuid.Nil, err } if err := tx.Commit(ctx); err != nil { return uuid.Nil, err } return newID, nil } func sanitizeAutoBody(body string) string { body = strings.TrimSpace(strings.ReplaceAll(body, "\x00", "")) if utf8.RuneCountInString(body) <= maxBodyLen { return body } return string([]rune(body)[:maxBodyLen]) }