package v1 import ( "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" "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 } 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 } // @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) // Support status filter via query param status := c.Query("status") var conversations []model.Conversation var total int64 if status != "" { result, count, svcErr := h.conversationSvc.ListByStatus(c.Request.Context(), accountID, status, p.Offset, p.PerPage) if svcErr != nil { handleServiceError(c, svcErr) return } conversations = result total = count } else { result, count, svcErr := h.conversationSvc.ListByAccount(c.Request.Context(), accountID, p.Offset, p.PerPage) if svcErr != nil { handleServiceError(c, svcErr) return } conversations = result total = count } c.JSON(http.StatusOK, serializeConversationList(c.Request.Context(), h.conversationSvc.DB(), conversations, total)) } // 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(c.Request.Context(), 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(c.Request.Context(), 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(c.Request.Context(), 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}, }) response.NoContent(c) } // @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 } 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 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.ToggleStatus(c.Request.Context(), 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, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) } // @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 } q := c.Query("q") if q == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "search query 'q' is required") return } p := pagination.Parse(c) searchMode := search.ParseSearchMode(c.DefaultQuery("search_mode", "")) conversations, total, svcErr := h.conversationSvc.Search(c.Request.Context(), accountID, q, p.Offset, p.PerPage, searchMode) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, serializeConversationList(c.Request.Context(), h.conversationSvc.DB(), conversations, total)) } // 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 { handleServiceError(c, svcErr) return } payload := serializeConversationList(c.Request.Context(), 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(c.Request.Context(), 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 err := c.ShouldBindJSON(&req); err != nil { 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 } meta, svcErr := h.conversationSvc.GetMeta(c.Request.Context(), accountID) if svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, 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(c.Request.Context(), 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 { 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 } 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" 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 } svcErr := h.conversationSvc.ToggleTyping(c.Request.Context(), accountID, conversation.ID, req.TypingStatus) 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) if svcErr != nil { handleServiceError(c, svcErr) return } c.Status(http.StatusOK) } // @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"` TeamID *uint `json:"team_id"` } 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.AssignTeam(c.Request.Context(), accountID, conversation.ID, req.AgentID, req.TeamID) if svcErr != nil { handleServiceError(c, svcErr) return } if req.AgentID != nil { c.JSON(http.StatusOK, serializeUserFromDB(c.Request.Context(), h.conversationSvc.DB(), *req.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(c.Request.Context(), 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() 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") { 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) if svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, payload) }