1182 lines
42 KiB
Go
1182 lines
42 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/gochat/gochat/internal/llm"
|
|
"github.com/gochat/gochat/internal/middleware"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/service"
|
|
"github.com/gochat/gochat/pkg/pagination"
|
|
"github.com/gochat/gochat/pkg/response"
|
|
|
|
"gorm.io/datatypes"
|
|
)
|
|
|
|
// ConversationHandler handles conversation-related API endpoints.
|
|
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb
|
|
type ConversationHandler struct {
|
|
conversationSvc *service.ConversationService
|
|
messageSvc *service.MessageService
|
|
auditSvc *service.AuditService
|
|
presence contactPresenceReader
|
|
}
|
|
|
|
const chatwootAttachmentResultsPerPage = 100
|
|
|
|
// NewConversationHandler creates a new ConversationHandler.
|
|
func NewConversationHandler(conversationSvc *service.ConversationService, messageSvc *service.MessageService) *ConversationHandler {
|
|
return &ConversationHandler{conversationSvc: conversationSvc, messageSvc: messageSvc}
|
|
}
|
|
|
|
func (h *ConversationHandler) WithAuditService(auditSvc *service.AuditService) *ConversationHandler {
|
|
h.auditSvc = auditSvc
|
|
return h
|
|
}
|
|
|
|
func (h *ConversationHandler) WithContactPresence(presence contactPresenceReader) *ConversationHandler {
|
|
h.presence = presence
|
|
return h
|
|
}
|
|
|
|
func (h *ConversationHandler) requestContext(c *gin.Context) context.Context {
|
|
return withContactPresence(c.Request.Context(), h.presence)
|
|
}
|
|
|
|
// @Summary List conversations for an account
|
|
// @Description Retrieves all conversations for an account with pagination, optionally filtered by status query param
|
|
// @Tags Conversations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param account_id path uint true "Account ID"
|
|
// @Param status query string false "Filter by conversation status (open/pending/resolved/snoozed)"
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param page_size query int false "Items per page" default(25)
|
|
// @Success 200 {object} []model.Conversation
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{account_id}/conversations [get]
|
|
func (h *ConversationHandler) List(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
p := pagination.Parse(c)
|
|
|
|
var params service.FilterParams
|
|
if err := c.ShouldBindQuery(¶ms); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
// Merge labels[] array params into Labels (comma-separated) for backward compat.
|
|
// Chatwoot frontend sends labels[]=xxx as separate query params.
|
|
if len(params.LabelsArr) > 0 {
|
|
if params.Labels != "" {
|
|
params.Labels = params.Labels + "," + strings.Join(params.LabelsArr, ",")
|
|
} else {
|
|
params.Labels = strings.Join(params.LabelsArr, ",")
|
|
}
|
|
}
|
|
|
|
result, svcErr := h.conversationSvc.ListWithFinder(c.Request.Context(), accountID, getUserID(c), params, p.Offset, p.PerPage)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
payload := serializeConversationList(h.requestContext(c), h.conversationSvc.DB(), result.Conversations, result.Count.AllCount)
|
|
payload.Data.Meta = chatwootConversationCounts{
|
|
MineCount: result.Count.MineCount,
|
|
AssignedCount: result.Count.AssignedCount,
|
|
UnassignedCount: result.Count.UnassignedCount,
|
|
AllCount: result.Count.AllCount,
|
|
}
|
|
c.JSON(http.StatusOK, payload)
|
|
}
|
|
|
|
// Create creates a new conversation.
|
|
// POST /api/v1/accounts/:account_id/conversations
|
|
// Reference: Chatwoot conversations#create
|
|
func (h *ConversationHandler) Create(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
var req service.CreateConversationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
|
|
conversation, svcErr := h.conversationSvc.Create(c.Request.Context(), accountID, req)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
recordAuditMutation(c, h.auditSvc, auditMutation{
|
|
AccountID: accountID,
|
|
AuditableType: "Conversation",
|
|
AuditableID: conversation.ID,
|
|
Action: "update",
|
|
AuditedChanges: gin.H{"status": conversation.Status, "priority": conversation.Priority, "sla_policy_id": conversation.SlaPolicyID},
|
|
})
|
|
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
|
}
|
|
|
|
// @Summary Get a single conversation
|
|
// @Description Retrieves detailed information about a specific conversation within an account
|
|
// @Tags Conversations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param account_id path uint true "Account ID"
|
|
// @Param id path uint true "Conversation ID"
|
|
// @Success 200 {object} model.Conversation
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{account_id}/conversations/{id} [get]
|
|
func (h *ConversationHandler) Get(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
conversation, svcErr := h.conversationSvc.GetByAccountAndDisplayIDOrID(c.Request.Context(), accountID, conversationID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
|
}
|
|
|
|
// Update updates a conversation (status, priority).
|
|
// PATCH /api/v1/accounts/:account_id/conversations/:id
|
|
// Reference: Chatwoot conversations#update
|
|
func (h *ConversationHandler) Update(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
var req service.UpdateConversationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
|
|
conversation, svcErr := h.conversationSvc.GetByAccountAndDisplayIDOrID(c.Request.Context(), accountID, conversationID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
conversation, svcErr = h.conversationSvc.Update(c.Request.Context(), accountID, conversation.ID, req)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
|
}
|
|
|
|
// Delete soft-deletes a conversation.
|
|
// DELETE /api/v1/accounts/:account_id/conversations/:id
|
|
func (h *ConversationHandler) Delete(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if svcErr := h.conversationSvc.Delete(c.Request.Context(), accountID, conversation.ID); svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
recordAuditMutation(c, h.auditSvc, auditMutation{
|
|
AccountID: accountID,
|
|
AuditableType: "Conversation",
|
|
AuditableID: conversation.ID,
|
|
Action: "destroy",
|
|
AuditedChanges: gin.H{"id": conversation.ID, "display_id": conversation.DisplayID},
|
|
})
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// @Summary Assign an agent to a conversation
|
|
// @Description Assigns a specific agent to a conversation by providing the assignee ID
|
|
// @Tags Conversations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param account_id path uint true "Account ID"
|
|
// @Param id path uint true "Conversation ID"
|
|
// @Param body body service.AssignAgentRequest true "Agent assignment payload"
|
|
// @Success 200 {object} model.Conversation
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{account_id}/conversations/{id}/assign [post]
|
|
func (h *ConversationHandler) AssignAgent(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
var req service.AssignAgentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if req.AssigneeType == "AgentBot" {
|
|
_, bot, svcErr := h.conversationSvc.AssignAgentBot(c.Request.Context(), accountID, conversation.ID, req.AssigneeID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
if bot == nil {
|
|
c.JSON(http.StatusOK, nil)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, serializeAgentBotSlim(bot))
|
|
return
|
|
}
|
|
|
|
conversation, svcErr := h.conversationSvc.AssignAgent(c.Request.Context(), accountID, conversation.ID, req.AssigneeID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
recordAuditMutation(c, h.auditSvc, auditMutation{
|
|
AccountID: accountID,
|
|
AuditableType: "Conversation",
|
|
AuditableID: conversation.ID,
|
|
Action: "update",
|
|
AuditedChanges: gin.H{"assignee_id": conversation.AssigneeID},
|
|
})
|
|
c.JSON(http.StatusOK, serializeUserFromDB(c.Request.Context(), h.conversationSvc.DB(), req.AssigneeID, accountID))
|
|
}
|
|
|
|
// @Summary Toggle conversation status
|
|
// @Description Toggles the status of a conversation between open, pending, resolved, or snoozed
|
|
// @Tags Conversations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param account_id path uint true "Account ID"
|
|
// @Param id path uint true "Conversation ID"
|
|
// @Param body body service.ToggleStatusRequest true "Status toggle payload"
|
|
// @Success 200 {object} model.Conversation
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{account_id}/conversations/{id}/toggle_status [post]
|
|
// ToggleStatus toggles the conversation status (open/pending/resolved/snoozed).
|
|
// POST /api/v1/accounts/:account_id/conversations/:id/toggle_status
|
|
// Reference: Chatwoot conversations#toggle_status
|
|
func (h *ConversationHandler) ToggleStatus(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
var req service.ToggleStatusRequest
|
|
if c.Request.Body != nil && c.Request.Body != http.NoBody {
|
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireConnectorShangwutongConversation(c, h.conversationSvc.DB(), conversation) {
|
|
return
|
|
}
|
|
requestContext := service.WithShangwutongRequestMetadata(c.Request.Context(), middleware.IsConnectorService(c), currentUserID(c))
|
|
conversation, svcErr := h.conversationSvc.ToggleStatus(requestContext, accountID, conversation.ID, req)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
recordAuditMutation(c, h.auditSvc, auditMutation{
|
|
AccountID: accountID,
|
|
AuditableType: "Conversation",
|
|
AuditableID: conversation.ID,
|
|
Action: "update",
|
|
AuditedChanges: gin.H{"status": conversation.Status, "snoozed_until": conversation.SnoozedUntil},
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"meta": gin.H{}, "payload": gin.H{
|
|
"success": true,
|
|
"conversation_id": conversationDisplayID(conversation),
|
|
"current_status": conversation.Status,
|
|
"snoozed_until": conversation.SnoozedUntil,
|
|
}})
|
|
}
|
|
|
|
// @Summary Mute a conversation
|
|
// @Description Mutes a conversation to suppress notifications
|
|
// @Tags Conversations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param account_id path uint true "Account ID"
|
|
// @Param id path uint true "Conversation ID"
|
|
// @Success 200 {object} model.Conversation
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{account_id}/conversations/{id}/mute [post]
|
|
// Mute mutes a conversation.
|
|
// POST /api/v1/accounts/:account_id/conversations/:id/mute
|
|
func (h *ConversationHandler) Mute(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
_, svcErr := h.conversationSvc.Mute(c.Request.Context(), accountID, conversation.ID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// @Summary Unmute a conversation
|
|
// @Description Unmutes a conversation to restore notifications
|
|
// @Tags Conversations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param account_id path uint true "Account ID"
|
|
// @Param id path uint true "Conversation ID"
|
|
// @Success 200 {object} model.Conversation
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{account_id}/conversations/{id}/unmute [post]
|
|
// Unmute unmutes a conversation.
|
|
// POST /api/v1/accounts/:account_id/conversations/:id/unmute
|
|
func (h *ConversationHandler) Unmute(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
_, svcErr := h.conversationSvc.Unmute(c.Request.Context(), accountID, conversation.ID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// UpdateLabels updates the labels on a conversation.
|
|
// PATCH /api/v1/accounts/:account_id/conversations/:id/labels
|
|
// Reference: Chatwoot conversations#labels
|
|
func (h *ConversationHandler) UpdateLabels(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
var req service.UpdateLabelsRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
conversation, svcErr := h.conversationSvc.UpdateLabels(c.Request.Context(), accountID, conversation.ID, req.Labels)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversationId": strconv.FormatUint(uint64(conversation.ID), 10), "labels": labelList(conversation.Labels)}})
|
|
}
|
|
|
|
// GetLabels returns the labels assigned to a conversation.
|
|
// GET /api/v1/accounts/:account_id/conversations/:conversation_id/labels
|
|
// Reference: Chatwoot dashboard conversationLabels store expects { payload: [] }.
|
|
func (h *ConversationHandler) GetLabels(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"payload": labelList(conversation.Labels)})
|
|
}
|
|
|
|
// @Summary Search conversations
|
|
// @Description Searches conversations by query string with pagination and search mode support
|
|
// @Tags Conversations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param account_id path uint true "Account ID"
|
|
// @Param q query string true "Search query"
|
|
// @Param search_mode query string false "Search mode (prefix/semantic/fulltext)" default(prefix)
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param page_size query int false "Items per page" default(25)
|
|
// @Success 200 {object} []model.Conversation
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{account_id}/conversations/search [get]
|
|
// Search searches conversations by label or metadata.
|
|
// GET /api/v1/accounts/:account_id/conversations/search?q=...
|
|
func (h *ConversationHandler) Search(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
p := pagination.Parse(c)
|
|
|
|
var params service.FilterParams
|
|
if err := c.ShouldBindQuery(¶ms); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
if len(params.LabelsArr) > 0 {
|
|
if params.Labels != "" {
|
|
params.Labels = params.Labels + "," + strings.Join(params.LabelsArr, ",")
|
|
} else {
|
|
params.Labels = strings.Join(params.LabelsArr, ",")
|
|
}
|
|
}
|
|
|
|
result, svcErr := h.conversationSvc.ListWithFinder(c.Request.Context(), accountID, getUserID(c), params, p.Offset, p.PerPage)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, serializeConversationSearchList(h.requestContext(c), h.conversationSvc.DB(), result.Conversations, result.Count))
|
|
}
|
|
|
|
// Filter retrieves conversations matching advanced filter criteria.
|
|
// POST /api/v1/accounts/:account_id/conversations/filter
|
|
// Reference: Chatwoot ConversationsController#filter — uses ConversationFinder with assignee_type/team_id/conversation_type
|
|
func (h *ConversationHandler) Filter(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
userID := getUserID(c)
|
|
|
|
var req service.FilterParams
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
|
|
p := pagination.Parse(c)
|
|
|
|
result, svcErr := h.conversationSvc.Filter(c.Request.Context(), accountID, userID, req, p.Offset, p.PerPage)
|
|
if svcErr != nil {
|
|
if len(req.Payload) > 0 {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
|
return
|
|
}
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
payload := serializeConversationList(h.requestContext(c), h.conversationSvc.DB(), result.Conversations, result.Count.AllCount)
|
|
payload.Data.Meta = chatwootConversationCounts{
|
|
MineCount: result.Count.MineCount,
|
|
AssignedCount: result.Count.AssignedCount,
|
|
UnassignedCount: result.Count.UnassignedCount,
|
|
AllCount: result.Count.AllCount,
|
|
}
|
|
c.JSON(http.StatusOK, payload.Data)
|
|
}
|
|
|
|
// UpdatePriority updates the priority of a conversation.
|
|
// PATCH /api/v1/accounts/:account_id/conversations/:id/priority
|
|
func (h *ConversationHandler) UpdatePriority(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Priority string `json:"priority" binding:"required,oneof=urgent high medium low none"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
conversation, svcErr := h.conversationSvc.UpdatePriority(c.Request.Context(), accountID, conversation.ID, req.Priority)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
|
}
|
|
|
|
// TogglePriority updates a conversation priority using Chatwoot's member action path.
|
|
// POST /api/v1/accounts/:account_id/conversations/:conversation_id/toggle_priority
|
|
// Reference: Chatwoot conversations#toggle_priority returns head :ok.
|
|
func (h *ConversationHandler) TogglePriority(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Priority *string `json:"priority"`
|
|
}
|
|
if c.Request.Body != nil && c.Request.Body != http.NoBody {
|
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
}
|
|
priority := ""
|
|
if req.Priority != nil {
|
|
priority = *req.Priority
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, svcErr := h.conversationSvc.UpdatePriority(c.Request.Context(), accountID, conversation.ID, priority); svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// ListMessages lists messages in a conversation.
|
|
// GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages
|
|
func (h *ConversationHandler) ListMessages(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
|
|
return
|
|
}
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
after, _ := strconv.ParseUint(c.Query("after"), 10, 64)
|
|
before, _ := strconv.ParseUint(c.Query("before"), 10, 64)
|
|
filterInternal := c.Query("filter_internal_messages") != ""
|
|
messages, _, svcErr := h.messageSvc.ListByConversationFinder(c.Request.Context(), conversation.ID, uint(after), uint(before), filterInternal)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, serializeMessageIndex(c.Request.Context(), h.conversationSvc.DB(), conversation, messages))
|
|
}
|
|
|
|
// Meta retrieves aggregated conversation metadata (status counts, label counts) for an account.
|
|
// GET /api/v1/accounts/:account_id/conversations/meta
|
|
// Reference: Chatwoot conversations_controller#meta
|
|
func (h *ConversationHandler) Meta(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
var params service.FilterParams
|
|
if err := c.ShouldBindQuery(¶ms); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
|
return
|
|
}
|
|
if params.Labels == "" {
|
|
labels := append(c.QueryArray("labels"), c.QueryArray("labels[]")...)
|
|
if len(labels) > 0 {
|
|
params.Labels = strings.Join(labels, ",")
|
|
}
|
|
}
|
|
|
|
meta, svcErr := h.conversationSvc.GetMeta(c.Request.Context(), accountID, currentUserID(c), params)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"meta": meta})
|
|
}
|
|
|
|
// Unread marks a conversation as unread by resetting agent_last_seen_at.
|
|
// POST /api/v1/accounts/:account_id/conversations/:id/unread
|
|
// Reference: Chatwoot conversations_controller#unread
|
|
func (h *ConversationHandler) Unread(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
conversation, svcErr := h.conversationSvc.MarkUnread(c.Request.Context(), accountID, conversation.ID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
|
}
|
|
|
|
// Transcript sends a conversation transcript via email.
|
|
// POST /api/v1/accounts/:account_id/conversations/:id/transcript
|
|
// Reference: Chatwoot conversations_controller#transcript
|
|
func (h *ConversationHandler) Transcript(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Email) == "" {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "email param missing"})
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if svcErr := h.conversationSvc.SendTranscript(c.Request.Context(), accountID, conversation.ID, req.Email); svcErr != nil {
|
|
if errors.Is(svcErr, service.ErrEmailTranscriptDisabled) {
|
|
c.JSON(http.StatusPaymentRequired, gin.H{"error": "Email transcript is not available on your plan"})
|
|
return
|
|
}
|
|
if errors.Is(svcErr, service.ErrEmailRateLimited) {
|
|
c.Status(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// UpdateCustomAttributes updates the custom attributes of a conversation.
|
|
// PATCH /api/v1/accounts/:account_id/conversations/:id/custom_attributes
|
|
// Reference: Chatwoot conversations_controller#custom_attributes
|
|
func (h *ConversationHandler) UpdateCustomAttributes(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
CustomAttributes datatypes.JSON `json:"custom_attributes" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireConnectorShangwutongConversation(c, h.conversationSvc.DB(), conversation) {
|
|
return
|
|
}
|
|
conversation, svcErr := h.conversationSvc.UpdateCustomAttributes(c.Request.Context(), accountID, conversation.ID, req.CustomAttributes)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"custom_attributes": jsonObject(conversation.CustomAttributes)})
|
|
}
|
|
|
|
// ListAttachments returns paginated message attachments for a conversation.
|
|
// GET /api/v1/accounts/:account_id/conversations/:id/attachments
|
|
// Reference: Chatwoot conversations_controller#attachments (member route)
|
|
func (h *ConversationHandler) ListAttachments(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
_, offset := fixedPageOffset(c, chatwootAttachmentResultsPerPage)
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
attachments, total, svcErr := h.messageSvc.ListAttachments(c.Request.Context(), accountID, conversation.ID, offset, chatwootAttachmentResultsPerPage)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
payload := make([]any, 0, len(attachments))
|
|
for i := range attachments {
|
|
payload = append(payload, serializeAttachment(c.Request.Context(), h.conversationSvc.DB(), &attachments[i]))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"meta": gin.H{"total_count": total}, "payload": payload})
|
|
}
|
|
|
|
// InboxAssistant returns the Captain assistant connected to the conversation inbox.
|
|
// GET /api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant
|
|
// Reference: Chatwoot enterprise conversations_controller#inbox_assistant.
|
|
func (h *ConversationHandler) InboxAssistant(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
|
|
return
|
|
}
|
|
|
|
assistant, svcErr := h.conversationSvc.GetInboxAssistant(c.Request.Context(), accountID, conversationID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
if assistant == nil {
|
|
c.JSON(http.StatusOK, gin.H{"assistant": nil})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"assistant": gin.H{"id": assistant.ID, "name": assistant.Name}})
|
|
}
|
|
|
|
// ReportingEvents returns raw reporting events for a conversation.
|
|
// GET /api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events
|
|
// Reference: Chatwoot enterprise conversations_controller#reporting_events.
|
|
func (h *ConversationHandler) ReportingEvents(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
|
|
return
|
|
}
|
|
|
|
events, svcErr := h.conversationSvc.ListReportingEvents(c.Request.Context(), accountID, conversationID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
payload := make([]gin.H, 0, len(events))
|
|
for i := range events {
|
|
payload = append(payload, serializeReportingEvent(events[i]))
|
|
}
|
|
c.JSON(http.StatusOK, payload)
|
|
}
|
|
|
|
func serializeReportingEvent(event model.ReportingEvent) gin.H {
|
|
return gin.H{
|
|
"id": event.ID,
|
|
"name": event.Name,
|
|
"value": event.Value,
|
|
"value_in_business_hours": event.ValueInBusinessHours,
|
|
"event_start_time": event.EventStartTime,
|
|
"event_end_time": event.EventEndTime,
|
|
"account_id": event.AccountID,
|
|
"inbox_id": event.InboxID,
|
|
"user_id": event.UserID,
|
|
"conversation_id": event.ConversationID,
|
|
"created_at": event.CreatedAt,
|
|
"updated_at": event.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// ToggleTyping toggles the typing status for an agent in a conversation.
|
|
// POST /api/v1/accounts/:account_id/conversations/:id/toggle_typing_status
|
|
// Reference: Chatwoot conversations_controller#toggle_typing_status (member route)
|
|
func (h *ConversationHandler) ToggleTyping(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
TypingStatus string `json:"typing_status"`
|
|
IsPrivate bool `json:"is_private"`
|
|
}
|
|
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
svcErr := h.conversationSvc.ToggleTyping(c.Request.Context(), accountID, conversation.ID, currentUserID(c), req.TypingStatus, req.IsPrivate)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// UpdateLastSeen updates the agent's last seen timestamp for a conversation.
|
|
// POST /api/v1/accounts/:account_id/conversations/:id/update_last_seen
|
|
// Reference: Chatwoot conversations_controller#update_last_seen (member route)
|
|
func (h *ConversationHandler) UpdateLastSeen(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
svcErr := h.conversationSvc.UpdateLastSeen(c.Request.Context(), accountID, conversation.ID, currentUserID(c))
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
updatedConversation, svcErr := h.conversationSvc.GetByAccountAndID(c.Request.Context(), accountID, conversation.ID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), updatedConversation))
|
|
}
|
|
|
|
// @Summary Assign a team to a conversation
|
|
// @Description Assigns a team (and optionally an agent) to a conversation
|
|
// @Tags Conversations
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param account_id path uint true "Account ID"
|
|
// @Param id path uint true "Conversation ID"
|
|
// @Param body body object true "Team assignment payload" example({"agent_id":1,"team_id":2})
|
|
// @Success 200 {object} model.Conversation
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{account_id}/conversations/{id}/assignments [post]
|
|
// AssignTeam assigns a team to a conversation (supports both agent_id and team_id).
|
|
// POST /api/v1/accounts/:account_id/conversations/:id/assignments
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/assignments_controller.rb
|
|
func (h *ConversationHandler) AssignTeam(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
conversationID, err := parseUintParam(c, "conversation_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
AgentID *uint `json:"agent_id"`
|
|
AssigneeID *uint `json:"assignee_id"`
|
|
TeamID *uint `json:"team_id"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
agentID := req.AgentID
|
|
if agentID == nil {
|
|
agentID = req.AssigneeID
|
|
}
|
|
|
|
conversation, ok := h.resolveConversationRoute(c, accountID, conversationID)
|
|
if !ok {
|
|
return
|
|
}
|
|
conversation, svcErr := h.conversationSvc.AssignTeam(c.Request.Context(), accountID, conversation.ID, agentID, req.TeamID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
if agentID != nil {
|
|
c.JSON(http.StatusOK, serializeUserFromDB(c.Request.Context(), h.conversationSvc.DB(), *agentID, accountID))
|
|
return
|
|
}
|
|
if req.TeamID != nil {
|
|
c.JSON(http.StatusOK, serializeTeamFromDB(c.Request.Context(), h.conversationSvc.DB(), *req.TeamID, accountID))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation))
|
|
}
|
|
|
|
// handleServiceError maps service-layer errors to appropriate HTTP responses.
|
|
// GORM record-not-found → 404, validation/invalid/required → 400, other → 500.
|
|
func handleServiceError(c *gin.Context, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
errMsg := err.Error()
|
|
if errors.Is(err, llm.ErrProviderNotConfigured) {
|
|
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrCopilotNotConfigured, errMsg)
|
|
return
|
|
}
|
|
var providerErr *llm.APIError
|
|
if errors.As(err, &providerErr) {
|
|
switch providerErr.StatusCode {
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotProviderAuth, "Copilot provider authentication failed")
|
|
case http.StatusNotFound:
|
|
response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotModelNotFound, "Copilot provider endpoint or model was not found")
|
|
case http.StatusTooManyRequests:
|
|
response.AbortWithStatusError(c, http.StatusTooManyRequests, response.ErrCopilotProviderRateLimited, "Copilot provider rate limit exceeded")
|
|
default:
|
|
response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotProviderUnreachable, "Copilot provider request failed")
|
|
}
|
|
return
|
|
}
|
|
var networkErr net.Error
|
|
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &networkErr) && networkErr.Timeout()) {
|
|
response.AbortWithStatusError(c, http.StatusGatewayTimeout, response.ErrCopilotProviderTimeout, "Copilot provider request timed out")
|
|
return
|
|
}
|
|
lower := strings.ToLower(errMsg)
|
|
if strings.Contains(lower, "not found") || strings.Contains(lower, "record not found") {
|
|
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, errMsg)
|
|
return
|
|
}
|
|
if strings.Contains(lower, "invalid") || strings.Contains(lower, "validation") || strings.Contains(lower, "required") || strings.Contains(lower, "unsupported file type") || strings.Contains(lower, "mime type") || strings.Contains(lower, "file size") {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, errMsg)
|
|
return
|
|
}
|
|
if strings.Contains(lower, "only allowed") || strings.Contains(lower, "forbidden") {
|
|
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, errMsg)
|
|
return
|
|
}
|
|
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, errMsg)
|
|
}
|
|
|
|
// toInterfaceSlice converts a typed slice to []interface{} for JSON serialization.
|
|
func toInterfaceSlice[T any](slice []T) []interface{} {
|
|
result := make([]interface{}, len(slice))
|
|
for i, v := range slice {
|
|
result[i] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (h *ConversationHandler) resolveConversationRoute(c *gin.Context, accountID, routeID uint) (*model.Conversation, bool) {
|
|
conversation, svcErr := h.conversationSvc.GetByAccountAndDisplayIDOrID(c.Request.Context(), accountID, routeID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return nil, false
|
|
}
|
|
return conversation, true
|
|
}
|
|
|
|
// UnreadCounts returns unread conversation counts grouped by inbox, label, and team.
|
|
// GET /api/v1/accounts/:account_id/conversations/unread_counts
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb
|
|
func (h *ConversationHandler) UnreadCounts(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
payload, svcErr := h.conversationSvc.GetUnreadCounts(c.Request.Context(), accountID, currentUserID(c))
|
|
if svcErr != nil {
|
|
if errors.Is(svcErr, service.ErrConversationUnreadCountsFeatureNotEnabled) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": svcErr.Error()})
|
|
return
|
|
}
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"payload": payload})
|
|
}
|