972 lines
34 KiB
Go
972 lines
34 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})
|
|
}
|
|
|
|
// 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)
|
|
}
|