Files
gochat/internal/handler/api/v1/conversation_handler.go
T

1046 lines
37 KiB
Go

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)
}